Merge branch 'dev' into 24-web-connect-events-page-to-dev-backend-poc
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
# IDE configuration files
|
# IDE configuration files
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
|
||||||
*.iml
|
*.iml
|
||||||
*.iws
|
*.iws
|
||||||
|
|
||||||
|
|||||||
69
.vscode/launch.json
vendored
Normal file
69
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "krow-staff-application (DEV iOS)",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"program": "mobile-apps/staff-app/lib/main_dev.dart",
|
||||||
|
"flutterMode": "debug",
|
||||||
|
"toolArgs": ["--flavor", "dev"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "krow-staff-application (DEV Android)",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"program": "mobile-apps/staff-app/lib/main_dev.dart",
|
||||||
|
"flutterMode": "debug",
|
||||||
|
"toolArgs": ["--flavor", "dev"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "krow-staff-application (STAGING iOS)",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"program": "mobile-apps/staff-app/lib/main_dev.dart",
|
||||||
|
"flutterMode": "debug",
|
||||||
|
"toolArgs": ["--flavor", "staging"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "krow-staff-application (STAGING Android)",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"program": "lmobile-apps/staff-app/lib/main_dev.dart",
|
||||||
|
"flutterMode": "debug",
|
||||||
|
"toolArgs": ["--flavor", "staging"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "krow-client-application (DEV iOS)",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"program": "mobile-apps/client-app/lib/main_dev.dart",
|
||||||
|
"flutterMode": "debug",
|
||||||
|
"toolArgs": ["--flavor", "dev"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "krow-client-application (DEV Android)",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"program": "lmobile-apps/client-app/lib/main_dev.dart",
|
||||||
|
"flutterMode": "debug",
|
||||||
|
"toolArgs": ["--flavor", "dev"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "krow-client-application (STAGING iOS)",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"program": "mobile-apps/client-app/lib/main_dev.dart",
|
||||||
|
"flutterMode": "debug",
|
||||||
|
"toolArgs": ["--flavor", "staging"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "krow-client-application (STAGING Android)",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"program": "lmobile-apps/client-app/lib/main_dev.dart",
|
||||||
|
"flutterMode": "debug",
|
||||||
|
"toolArgs": ["--flavor", "staging"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
113
Makefile
113
Makefile
@@ -4,11 +4,17 @@
|
|||||||
# It is designed to be the main entry point for developers.
|
# It is designed to be the main entry point for developers.
|
||||||
|
|
||||||
# Use .PHONY to declare targets that are not files, to avoid conflicts.
|
# Use .PHONY to declare targets that are not files, to avoid conflicts.
|
||||||
.PHONY: help install dev build integrate-export prepare-export deploy-launchpad deploy-launchpad-full deploy-app admin-install admin-dev admin-build deploy-admin deploy-admin-full configure-iap-launchpad configure-iap-admin list-iap-users remove-iap-user setup-labels export-issues create-issues-from-file install-git-hooks
|
.PHONY: help install dev build integrate-export prepare-export deploy-launchpad deploy-launchpad-full deploy-app admin-install admin-dev admin-build deploy-admin deploy-admin-full configure-iap-launchpad configure-iap-admin list-iap-users remove-iap-user setup-labels export-issues create-issues-from-file install-git-hooks mobile-client-install mobile-client-dev mobile-client-build mobile-staff-install mobile-staff-dev mobile-staff-build
|
||||||
|
|
||||||
# The default command to run if no target is specified (e.g., just 'make').
|
# The default command to run if no target is specified (e.g., just 'make').
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
# --- Flutter check ---
|
||||||
|
FLUTTER := $(shell which flutter)
|
||||||
|
ifeq ($(FLUTTER),)
|
||||||
|
$(error "flutter not found in PATH. Please install Flutter and add it to your PATH.")
|
||||||
|
endif
|
||||||
|
|
||||||
# --- Firebase & GCP Configuration ---
|
# --- Firebase & GCP Configuration ---
|
||||||
GCP_DEV_PROJECT_ID := krow-workforce-dev
|
GCP_DEV_PROJECT_ID := krow-workforce-dev
|
||||||
GCP_STAGING_PROJECT_ID := krow-workforce-staging
|
GCP_STAGING_PROJECT_ID := krow-workforce-staging
|
||||||
@@ -61,6 +67,15 @@ help:
|
|||||||
@echo " make dev - Starts the local web frontend server."
|
@echo " make dev - Starts the local web frontend server."
|
||||||
@echo " make build - Builds the web frontend for production."
|
@echo " make build - Builds the web frontend for production."
|
||||||
@echo ""
|
@echo ""
|
||||||
|
@echo " --- MOBILE APP DEVELOPMENT ---"
|
||||||
|
@echo " make mobile-client-install - Install dependencies for client app"
|
||||||
|
@echo " make mobile-client-dev - Run client app in dev mode"
|
||||||
|
@echo " make mobile-client-build - Build client app (requires ENV & PLATFORM, optional BUILD_TYPE=apk)"
|
||||||
|
@echo ""
|
||||||
|
@echo " make mobile-staff-install - Install dependencies for staff app"
|
||||||
|
@echo " make mobile-staff-dev - Run staff app in dev mode"
|
||||||
|
@echo " make mobile-staff-build - Build staff app (requires ENV & PLATFORM, optional BUILD_TYPE=apk)"
|
||||||
|
@echo ""
|
||||||
@echo " --- DEPLOYMENT ---"
|
@echo " --- DEPLOYMENT ---"
|
||||||
@echo " make deploy-launchpad-full - Deploys internal launchpad to Cloud Run (dev only) with IAP."
|
@echo " make deploy-launchpad-full - Deploys internal launchpad to Cloud Run (dev only) with IAP."
|
||||||
@echo " make deploy-admin-full [ENV=staging] - Deploys Admin Console to Cloud Run with IAP (default: dev)."
|
@echo " make deploy-admin-full [ENV=staging] - Deploys Admin Console to Cloud Run with IAP (default: dev)."
|
||||||
@@ -72,7 +87,7 @@ help:
|
|||||||
@echo ""
|
@echo ""
|
||||||
@echo " --- PROJECT MANAGEMENT & TOOLS ---"
|
@echo " --- PROJECT MANAGEMENT & TOOLS ---"
|
||||||
@echo " make setup-labels - Creates/updates GitHub labels from labels.yml."
|
@echo " make setup-labels - Creates/updates GitHub labels from labels.yml."
|
||||||
@echo " make export-issues [ARGS="--state=all --label=bug"] - Exports GitHub issues to a markdown file. See scripts/export_issues.sh for options."
|
@echo " make export-issues [ARGS=\"--state=all --label=bug\"] - Exports GitHub issues to a markdown file. See scripts/export_issues.sh for options."
|
||||||
@echo " make create-issues-from-file - Bulk creates GitHub issues from a markdown file."
|
@echo " make create-issues-from-file - Bulk creates GitHub issues from a markdown file."
|
||||||
@echo " make install-git-hooks - Installs git pre-push hook to protect main/dev branches."
|
@echo " make install-git-hooks - Installs git pre-push hook to protect main/dev branches."
|
||||||
@echo ""
|
@echo ""
|
||||||
@@ -145,6 +160,23 @@ admin-build:
|
|||||||
@node scripts/patch-admin-layout-for-env-label.js
|
@node scripts/patch-admin-layout-for-env-label.js
|
||||||
@cd admin-web && VITE_APP_ENV=$(ENV) npm run build
|
@cd admin-web && VITE_APP_ENV=$(ENV) npm run build
|
||||||
|
|
||||||
|
# --- API Test Harness ---
|
||||||
|
harness-install:
|
||||||
|
@echo "--> Installing API Test Harness dependencies..."
|
||||||
|
@cd internal-api-harness && npm install
|
||||||
|
|
||||||
|
harness-dev:
|
||||||
|
@echo "--> Starting API Test Harness development server on http://localhost:5175 ..."
|
||||||
|
@cd internal-api-harness && npm run dev -- --port 5175
|
||||||
|
|
||||||
|
harness-build:
|
||||||
|
@echo "--> Building API Test Harness for production..."
|
||||||
|
@cd internal-api-harness && npm run build -- --mode $(ENV)
|
||||||
|
|
||||||
|
harness-deploy: harness-build
|
||||||
|
@echo "--> Deploying API Test Harness to [$(ENV)] environment..."
|
||||||
|
@firebase deploy --only hosting:api-harness-$(ENV) --project=$(FIREBASE_ALIAS)
|
||||||
|
|
||||||
deploy-admin: admin-build
|
deploy-admin: admin-build
|
||||||
@echo "--> Building and deploying Admin Console to Cloud Run [$(ENV)]..."
|
@echo "--> Building and deploying Admin Console to Cloud Run [$(ENV)]..."
|
||||||
@echo " - Step 1: Building container image..."
|
@echo " - Step 1: Building container image..."
|
||||||
@@ -176,7 +208,7 @@ configure-iap-launchpad:
|
|||||||
@gcloud run services add-iam-policy-binding $(CR_LAUNCHPAD_SERVICE_NAME) \
|
@gcloud run services add-iam-policy-binding $(CR_LAUNCHPAD_SERVICE_NAME) \
|
||||||
--region=$(CR_LAUNCHPAD_REGION) \
|
--region=$(CR_LAUNCHPAD_REGION) \
|
||||||
--project=$(GCP_DEV_PROJECT_ID) \
|
--project=$(GCP_DEV_PROJECT_ID) \
|
||||||
--member="serviceAccount:$(IAP_SERVICE_ACCOUNT)" \
|
--member=\"serviceAccount:$(IAP_SERVICE_ACCOUNT)\" \
|
||||||
--role='roles/run.invoker' \
|
--role='roles/run.invoker' \
|
||||||
--quiet
|
--quiet
|
||||||
@echo " - Adding users from iap-users.txt..."
|
@echo " - Adding users from iap-users.txt..."
|
||||||
@@ -188,7 +220,7 @@ configure-iap-launchpad:
|
|||||||
--resource-type=cloud-run \
|
--resource-type=cloud-run \
|
||||||
--service=$(CR_LAUNCHPAD_SERVICE_NAME) \
|
--service=$(CR_LAUNCHPAD_SERVICE_NAME) \
|
||||||
--region=$(CR_LAUNCHPAD_REGION) \
|
--region=$(CR_LAUNCHPAD_REGION) \
|
||||||
--member="$$member" \
|
--member=\"$$member\" \
|
||||||
--role='roles/iap.httpsResourceAccessor' \
|
--role='roles/iap.httpsResourceAccessor' \
|
||||||
--quiet; \
|
--quiet; \
|
||||||
done
|
done
|
||||||
@@ -200,7 +232,7 @@ configure-iap-admin:
|
|||||||
@gcloud run services add-iam-policy-binding $(CR_ADMIN_SERVICE_NAME) \
|
@gcloud run services add-iam-policy-binding $(CR_ADMIN_SERVICE_NAME) \
|
||||||
--region=$(CR_ADMIN_REGION) \
|
--region=$(CR_ADMIN_REGION) \
|
||||||
--project=$(GCP_PROJECT_ID) \
|
--project=$(GCP_PROJECT_ID) \
|
||||||
--member="serviceAccount:$(IAP_SERVICE_ACCOUNT)" \
|
--member=\"serviceAccount:$(IAP_SERVICE_ACCOUNT)\" \
|
||||||
--role='roles/run.invoker' \
|
--role='roles/run.invoker' \
|
||||||
--quiet
|
--quiet
|
||||||
@echo " - Adding users from iap-users.txt..."
|
@echo " - Adding users from iap-users.txt..."
|
||||||
@@ -212,7 +244,7 @@ configure-iap-admin:
|
|||||||
--resource-type=cloud-run \
|
--resource-type=cloud-run \
|
||||||
--service=$(CR_ADMIN_SERVICE_NAME) \
|
--service=$(CR_ADMIN_SERVICE_NAME) \
|
||||||
--region=$(CR_ADMIN_REGION) \
|
--region=$(CR_ADMIN_REGION) \
|
||||||
--member="$$member" \
|
--member=\"$$member\" \
|
||||||
--role='roles/iap.httpsResourceAccessor' \
|
--role='roles/iap.httpsResourceAccessor' \
|
||||||
--quiet; \
|
--quiet; \
|
||||||
done
|
done
|
||||||
@@ -237,7 +269,7 @@ remove-iap-user:
|
|||||||
--resource-type=cloud-run \
|
--resource-type=cloud-run \
|
||||||
--service=$(IAP_SERVICE_NAME) \
|
--service=$(IAP_SERVICE_NAME) \
|
||||||
--region=$(IAP_SERVICE_REGION) \
|
--region=$(IAP_SERVICE_REGION) \
|
||||||
--member="$(USER)" \
|
--member=\"$(USER)\" \
|
||||||
--role='roles/iap.httpsResourceAccessor' \
|
--role='roles/iap.httpsResourceAccessor' \
|
||||||
--quiet
|
--quiet
|
||||||
@echo "✅ User removed from IAP."
|
@echo "✅ User removed from IAP."
|
||||||
@@ -312,4 +344,69 @@ dataconnect-init:
|
|||||||
dataconnect-deploy:
|
dataconnect-deploy:
|
||||||
@echo "--> Deploying Firebase Data Connect schemas to [$(ENV)] (project: $(FIREBASE_ALIAS))..."
|
@echo "--> Deploying Firebase Data Connect schemas to [$(ENV)] (project: $(FIREBASE_ALIAS))..."
|
||||||
@firebase deploy --only dataconnect --project=$(FIREBASE_ALIAS)
|
@firebase deploy --only dataconnect --project=$(FIREBASE_ALIAS)
|
||||||
@echo "✅ Data Connect deployment completed for [$(ENV)]."
|
@echo "✅ Data Connect deployment completed for [$(ENV)]."
|
||||||
|
|
||||||
|
# --- Mobile App Development ---
|
||||||
|
FLAVOR :=
|
||||||
|
ifeq ($(ENV),dev)
|
||||||
|
FLAVOR := dev
|
||||||
|
else ifeq ($(ENV),staging)
|
||||||
|
FLAVOR := staging
|
||||||
|
else ifeq ($(ENV),prod)
|
||||||
|
FLAVOR := production
|
||||||
|
endif
|
||||||
|
|
||||||
|
BUILD_TYPE ?= appbundle
|
||||||
|
|
||||||
|
mobile-client-install:
|
||||||
|
@echo "--> Installing Flutter dependencies for client app..."
|
||||||
|
@cd mobile-apps/client-app && $(FLUTTER) pub get
|
||||||
|
|
||||||
|
mobile-client-dev:
|
||||||
|
@echo "--> Running client app in dev mode..."
|
||||||
|
@echo "--> If using VS code, use the debug configurations"
|
||||||
|
@cd mobile-apps/client-app && $(FLUTTER) run --flavor dev -t lib/main_dev.dart
|
||||||
|
|
||||||
|
mobile-client-build:
|
||||||
|
@if [ "$(ENV)" != "dev" ] && [ "$(ENV)" != "staging" ] && [ "$(ENV)" != "prod" ]; then \
|
||||||
|
echo "ERROR: ENV must be one of dev, staging, or prod."; exit 1; \
|
||||||
|
fi
|
||||||
|
@if [ "$(PLATFORM)" != "android" ] && [ "$(PLATFORM)" != "ios" ]; then \
|
||||||
|
echo "ERROR: PLATFORM must be either android or ios."; exit 1; \
|
||||||
|
fi
|
||||||
|
@echo "--> Building client app for $(PLATFORM) with flavor $(FLAVOR)..."
|
||||||
|
@cd mobile-apps/client-app && \
|
||||||
|
$(FLUTTER) pub get && \
|
||||||
|
$(FLUTTER) pub run build_runner build --delete-conflicting-outputs && \
|
||||||
|
if [ "$(PLATFORM)" = "android" ]; then \
|
||||||
|
$(FLUTTER) build $(BUILD_TYPE) --flavor $(FLAVOR); \
|
||||||
|
elif [ "$(PLATFORM)" = "ios" ]; then \
|
||||||
|
$(FLUTTER) build ipa --flavor $(FLAVOR); \
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
mobile-staff-install:
|
||||||
|
@echo "--> Installing Flutter dependencies for staff app..."
|
||||||
|
@cd mobile-apps/staff-app && $(FLUTTER) pub get
|
||||||
|
|
||||||
|
mobile-staff-dev:
|
||||||
|
@echo "--> Running staff app in dev mode..."
|
||||||
|
@echo "--> If using VS code, use the debug configurations"
|
||||||
|
@cd mobile-apps/staff-app && $(FLUTTER) run --flavor dev -t lib/main_dev.dart
|
||||||
|
|
||||||
|
mobile-staff-build:
|
||||||
|
@if [ "$(ENV)" != "dev" ] && [ "$(ENV)" != "staging" ] && [ "$(ENV)" != "prod" ]; then \
|
||||||
|
echo "ERROR: ENV must be one of dev, staging, or prod."; exit 1; \
|
||||||
|
fi
|
||||||
|
@if [ "$(PLATFORM)" != "android" ] && [ "$(PLATFORM)" != "ios" ]; then \
|
||||||
|
echo "ERROR: PLATFORM must be either android or ios."; exit 1; \
|
||||||
|
fi
|
||||||
|
@echo "--> Building staff app for $(PLATFORM) with flavor $(FLAVOR)..."
|
||||||
|
@cd mobile-apps/staff-app && \
|
||||||
|
$(FLUTTER) pub get && \
|
||||||
|
$(FLUTTER) pub run build_runner build --delete-conflicting-outputs && \
|
||||||
|
if [ "$(PLATFORM)" = "android" ]; then \
|
||||||
|
$(FLUTTER) build $(BUILD_TYPE) --flavor $(FLAVOR); \
|
||||||
|
elif [ "$(PLATFORM)" = "ios" ]; then \
|
||||||
|
$(FLUTTER) build ipa --flavor $(FLAVOR); \
|
||||||
|
fi
|
||||||
141
build.gradle
Normal file
141
build.gradle
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
plugins {
|
||||||
|
id "com.android.application"
|
||||||
|
// START: FlutterFire Configuration
|
||||||
|
id 'com.google.gms.google-services'
|
||||||
|
id 'com.google.firebase.crashlytics'
|
||||||
|
// END: FlutterFire Configuration
|
||||||
|
id "kotlin-android"
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id "dev.flutter.flutter-gradle-plugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.troywallet"
|
||||||
|
compileSdk = 36
|
||||||
|
ndkVersion = "25.1.8937393"
|
||||||
|
//ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
coreLibraryDesugaringEnabled true
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId = "com.example.troywallet"
|
||||||
|
namespace("com.example.troywallet")
|
||||||
|
minSdk = 29
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
multiDexEnabled true
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyStore //////////////////////////////////////////////////
|
||||||
|
// - kast.keystore..alias android..pw=android..pw=android
|
||||||
|
def keystorePropertiesFileNameDev = 'kast_key_dev.properties'
|
||||||
|
def keystorePropertiesDev = new Properties()
|
||||||
|
keystorePropertiesDev.load(new FileInputStream(rootProject.file(keystorePropertiesFileNameDev)))
|
||||||
|
|
||||||
|
def keystorePropertiesFileNameUat = 'kast_key_uat.properties'
|
||||||
|
def keystorePropertiesUat = new Properties()
|
||||||
|
keystorePropertiesUat.load(new FileInputStream(rootProject.file(keystorePropertiesFileNameUat)))
|
||||||
|
|
||||||
|
def keystorePropertiesFileNameStaging = 'kast_key_staging.properties'
|
||||||
|
def keystorePropertiesStaging = new Properties()
|
||||||
|
keystorePropertiesStaging.load(new FileInputStream(rootProject.file(keystorePropertiesFileNameStaging)))
|
||||||
|
|
||||||
|
def keystorePropertiesFileNameProd = 'kast_key_prod.properties'
|
||||||
|
def keystorePropertiesProd = new Properties()
|
||||||
|
keystorePropertiesProd.load(new FileInputStream(rootProject.file(keystorePropertiesFileNameProd)))
|
||||||
|
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
configDev {
|
||||||
|
keyAlias keystorePropertiesDev['keyAlias']
|
||||||
|
keyPassword keystorePropertiesDev['keyPassword']
|
||||||
|
storeFile file('../kast_android_dev.jks') //keystorePropertiesSit['storeFile']
|
||||||
|
storePassword keystorePropertiesDev['storePassword']
|
||||||
|
}
|
||||||
|
configUat {
|
||||||
|
keyAlias keystorePropertiesUat['keyAlias']
|
||||||
|
keyPassword keystorePropertiesUat['keyPassword']
|
||||||
|
storeFile file('../kast_android_uat.jks') //keystorePropertiesPat['storeFile']
|
||||||
|
storePassword keystorePropertiesUat['storePassword']
|
||||||
|
}
|
||||||
|
|
||||||
|
configStaging {
|
||||||
|
keyAlias keystorePropertiesStaging['keyAlias']
|
||||||
|
keyPassword keystorePropertiesStaging['keyPassword']
|
||||||
|
storeFile file('../kast_android_staging.jks') //keystorePropertiesPat['storeFile']
|
||||||
|
storePassword keystorePropertiesStaging['storePassword']
|
||||||
|
}
|
||||||
|
configProd {
|
||||||
|
keyAlias keystorePropertiesProd['keyAlias']
|
||||||
|
keyPassword keystorePropertiesProd['keyPassword']
|
||||||
|
storeFile file('../kast_android_prod.jks')
|
||||||
|
storePassword keystorePropertiesProd['storePassword']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flavorDimensions "default"
|
||||||
|
productFlavors {
|
||||||
|
dev {
|
||||||
|
dimension "default"
|
||||||
|
applicationId "dev.kastcard.com"
|
||||||
|
resValue "string", "app_name", "KAST DEV"
|
||||||
|
resValue "string", "deeplink_prefix", "/dls"
|
||||||
|
resValue "drawable", "ic_launcher", "@mipmap/ic_launcher_dev"
|
||||||
|
signingConfig signingConfigs.configDev
|
||||||
|
}
|
||||||
|
uat {
|
||||||
|
dimension "default"
|
||||||
|
applicationId "uat.kastcard.com"
|
||||||
|
resValue "string", "app_name", "KAST UAT"
|
||||||
|
resValue "string", "deeplink_prefix", "/dlp"
|
||||||
|
resValue "drawable", "ic_launcher", "@mipmap/ic_launcher_uat"
|
||||||
|
signingConfig signingConfigs.configUat
|
||||||
|
}
|
||||||
|
staging {
|
||||||
|
dimension "default"
|
||||||
|
applicationId "stg.kastcard.com"
|
||||||
|
resValue "string", "app_name", "KAST Staging"
|
||||||
|
resValue "string", "deeplink_prefix", "/dlp"
|
||||||
|
resValue "drawable", "ic_launcher", "@mipmap/ic_launcher_staging"
|
||||||
|
signingConfig signingConfigs.configStaging
|
||||||
|
}
|
||||||
|
prod {
|
||||||
|
dimension "default"
|
||||||
|
applicationId "com.kastfinance.app"
|
||||||
|
resValue "string", "app_name", "KAST"
|
||||||
|
resValue "string", "deeplink_prefix", "/dlp"
|
||||||
|
resValue "drawable", "ic_launcher", "@mipmap/ic_launcher_prod"
|
||||||
|
signingConfig signingConfigs.configProd
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
shrinkResources false
|
||||||
|
minifyEnabled false
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation "com.sumsub.sns:idensic-mobile-sdk-videoident:1.33.1"
|
||||||
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2'
|
||||||
|
implementation(files("libs/mdisdk-release-1.2.54.aar"))
|
||||||
|
}
|
||||||
101
codemagic-env-vars.md
Normal file
101
codemagic-env-vars.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Codemagic Environment Variables
|
||||||
|
|
||||||
|
This document outlines the environment variables required for the Codemagic CI/CD pipelines defined in `codemagic.yaml`. These variables should be configured in your Codemagic project under **Environment variables**.
|
||||||
|
|
||||||
|
## Client App (`client-app`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Group: `client_app_dev_credentials`
|
||||||
|
|
||||||
|
| Variable Name | Example Value | Secure | Description |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `FLAVOR` | `dev` | No | The Flutter flavor to use for the build. |
|
||||||
|
| `FIREBASE_APP_ID_ANDROID` | `1:DEV_ANDROID_APP_ID` | No | The Firebase App ID for the Android app (Dev). |
|
||||||
|
| `FIREBASE_APP_ID_IOS` | `1:DEV_IOS_APP_ID` | No | The Firebase App ID for the iOS app (Dev). |
|
||||||
|
| `FIREBASE_TESTER_GROUPS` | `developers` | No | Comma-separated list of Firebase tester groups. |
|
||||||
|
| `FIREBASE_TOKEN` | `(your_firebase_token)` | Yes | Your Firebase CLI token. |
|
||||||
|
| `GOOGLE_SERVICES_JSON` | `(contents of google-services.json)` | Yes | Contents of your `google-services.json` file for Android (Dev). |
|
||||||
|
| `GOOGLE_SERVICE_INFO_PLIST` | `(contents of GoogleService-Info.plist)` | Yes | Contents of your `GoogleService-Info.plist` file for iOS (Dev). |
|
||||||
|
| `KEYSTORE_PASSWORD` | `(your_keystore_password)` | Yes | Password for the Android keystore. |
|
||||||
|
| `KEY_ALIAS` | `(your_key_alias)` | Yes | Alias for the key in the Android keystore. |
|
||||||
|
| `KEY_PASSWORD` | `(your_key_password)` | Yes | Password for the key in the Android keystore. |
|
||||||
|
|
||||||
|
### Group: `client_app_staging_credentials`
|
||||||
|
|
||||||
|
| Variable Name | Example Value | Secure | Description |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `FLAVOR` | `staging` | No | The Flutter flavor to use for the build. |
|
||||||
|
| `FIREBASE_APP_ID_ANDROID` | `1:STAGING_ANDROID_APP_ID` | No | The Firebase App ID for the Android app (Staging). |
|
||||||
|
| `FIREBASE_APP_ID_IOS` | `1:STAGING_IOS_APP_ID` | No | The Firebase App ID for the iOS app (Staging). |
|
||||||
|
| `FIREBASE_TESTER_GROUPS` | `qa-team, stakeholders` | No | Comma-separated list of Firebase tester groups. |
|
||||||
|
| `FIREBASE_TOKEN` | `(your_firebase_token)` | Yes | Your Firebase CLI token. |
|
||||||
|
| `GOOGLE_SERVICES_JSON` | `(contents of google-services.json)` | Yes | Contents of your `google-services.json` file for Android (Staging). |
|
||||||
|
| `GOOGLE_SERVICE_INFO_PLIST` | `(contents of GoogleService-Info.plist)` | Yes | Contents of your `GoogleService-Info.plist` file for iOS (Staging). |
|
||||||
|
| `KEYSTORE_PASSWORD` | `(your_keystore_password)` | Yes | Password for the Android keystore. |
|
||||||
|
| `KEY_ALIAS` | `(your_key_alias)` | Yes | Alias for the key in the Android keystore. |
|
||||||
|
| `KEY_PASSWORD` | `(your_key_password)` | Yes | Password for the key in the Android keystore. |
|
||||||
|
|
||||||
|
### Group: `client_app_prod_credentials`
|
||||||
|
|
||||||
|
| Variable Name | Example Value | Secure | Description |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `FLAVOR` | `prod` | No | The Flutter flavor to use for the build. |
|
||||||
|
| `FIREBASE_APP_ID_ANDROID` | `1:PROD_ANDROID_APP_ID` | No | The Firebase App ID for the Android app (Prod). |
|
||||||
|
| `FIREBASE_APP_ID_IOS` | `1:PROD_IOS_APP_ID` | No | The Firebase App ID for the iOS app (Prod). |
|
||||||
|
| `FIREBASE_TESTER_GROUPS` | `(empty or specific group)` | No | Comma-separated list of Firebase tester groups. |
|
||||||
|
| `FIREBASE_TOKEN` | `(your_firebase_token)` | Yes | Your Firebase CLI token. |
|
||||||
|
| `GOOGLE_SERVICES_JSON` | `(contents of google-services.json)` | Yes | Contents of your `google-services.json` file for Android (Prod). |
|
||||||
|
| `GOOGLE_SERVICE_INFO_PLIST` | `(contents of GoogleService-Info.plist)` | Yes | Contents of your `GoogleService-Info.plist` file for iOS (Prod). |
|
||||||
|
| `KEYSTORE_PASSWORD` | `(your_keystore_password)` | Yes | Password for the Android keystore. |
|
||||||
|
| `KEY_ALIAS` | `(your_key_alias)` | Yes | Alias for the key in the Android keystore. |
|
||||||
|
| `KEY_PASSWORD` | `(your_key_password)` | Yes | Password for the key in the Android keystore. |
|
||||||
|
|
||||||
|
## Staff App (`staff-app`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Group: `staff_app_dev_credentials`
|
||||||
|
|
||||||
|
| Variable Name | Example Value | Secure | Description |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `FLAVOR` | `dev` | No | The Flutter flavor to use for the build. |
|
||||||
|
| `FIREBASE_APP_ID_ANDROID` | `1:DEV_ANDROID_APP_ID` | No | The Firebase App ID for the Android app (Dev). |
|
||||||
|
| `FIREBASE_APP_ID_IOS` | `1:DEV_IOS_APP_ID` | No | The Firebase App ID for the iOS app (Dev). |
|
||||||
|
| `FIREBASE_TESTER_GROUPS` | `developers` | No | Comma-separated list of Firebase tester groups. |
|
||||||
|
| `FIREBASE_TOKEN` | `(your_firebase_token)` | Yes | Your Firebase CLI token. |
|
||||||
|
| `GOOGLE_SERVICES_JSON` | `(contents of google-services.json)` | Yes | Contents of your `google-services.json` file for Android (Dev). |
|
||||||
|
| `GOOGLE_SERVICE_INFO_PLIST` | `(contents of GoogleService-Info.plist)` | Yes | Contents of your `GoogleService-Info.plist` file for iOS (Dev). |
|
||||||
|
| `KEYSTORE_PASSWORD` | `(your_keystore_password)` | Yes | Password for the Android keystore. |
|
||||||
|
| `KEY_ALIAS` | `(your_key_alias)` | Yes | Alias for the key in the Android keystore. |
|
||||||
|
| `KEY_PASSWORD` | `(your_key_password)` | Yes | Password for the key in the Android keystore. |
|
||||||
|
|
||||||
|
### Group: `staff_app_staging_credentials`
|
||||||
|
|
||||||
|
| Variable Name | Example Value | Secure | Description |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `FLAVOR` | `staging` | No | The Flutter flavor to use for the build. |
|
||||||
|
| `FIREBASE_APP_ID_ANDROID` | `1:STAGING_ANDROID_APP_ID` | No | The Firebase App ID for the Android app (Staging). |
|
||||||
|
| `FIREBASE_APP_ID_IOS` | `1:STAGING_IOS_APP_ID` | No | The Firebase App ID for the iOS app (Staging). |
|
||||||
|
| `FIREBASE_TESTER_GROUPS` | `qa-team, stakeholders` | No | Comma-separated list of Firebase tester groups. |
|
||||||
|
| `FIREBASE_TOKEN` | `(your_firebase_token)` | Yes | Your Firebase CLI token. |
|
||||||
|
| `GOOGLE_SERVICES_JSON` | `(contents of google-services.json)` | Yes | Contents of your `google-services.json` file for Android (Staging). |
|
||||||
|
| `GOOGLE_SERVICE_INFO_PLIST` | `(contents of GoogleService-Info.plist)` | Yes | Contents of your `GoogleService-Info.plist` file for iOS (Staging). |
|
||||||
|
| `KEYSTORE_PASSWORD` | `(your_keystore_password)` | Yes | Password for the Android keystore. |
|
||||||
|
| `KEY_ALIAS` | `(your_key_alias)` | Yes | Alias for the key in the Android keystore. |
|
||||||
|
| `KEY_PASSWORD` | `(your_key_password)` | Yes | Password for the key in the Android keystore. |
|
||||||
|
|
||||||
|
### Group: `staff_app_prod_credentials`
|
||||||
|
|
||||||
|
| Variable Name | Example Value | Secure | Description |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `FLAVOR` | `prod` | No | The Flutter flavor to use for the build. |
|
||||||
|
| `FIREBASE_APP_ID_ANDROID` | `1:PROD_ANDROID_APP_ID` | No | The Firebase App ID for the Android app (Prod). |
|
||||||
|
| `FIREBASE_APP_ID_IOS` | `1:PROD_IOS_APP_ID` | No | The Firebase App ID for the iOS app (Prod). |
|
||||||
|
| `FIREBASE_TESTER_GROUPS` | `(empty or specific group)` | No | Comma-separated list of Firebase tester groups. |
|
||||||
|
| `FIREBASE_TOKEN` | `(your_firebase_token)` | Yes | Your Firebase CLI token. |
|
||||||
|
| `GOOGLE_SERVICES_JSON` | `(contents of google-services.json)` | Yes | Contents of your `google-services.json` file for Android (Prod). |
|
||||||
|
| `GOOGLE_SERVICE_INFO_PLIST` | `(contents of GoogleService-Info.plist)` | Yes | Contents of your `GoogleService-Info.plist` file for iOS (Prod). |
|
||||||
|
| `KEYSTORE_PASSWORD` | `(your_keystore_password)` | Yes | Password for the Android keystore. |
|
||||||
|
| `KEY_ALIAS` | `(your_key_alias)` | Yes | Alias for the key in the Android keystore. |
|
||||||
|
| `KEY_PASSWORD` | `(your_key_password)` | Yes | Password for the key in the Android keystore. |
|
||||||
236
codemagic.yaml
Normal file
236
codemagic.yaml
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# Reusable script for building the Flutter app
|
||||||
|
client-app-android-apk-build-script: &client-app-android-apk-build-script
|
||||||
|
name: 👷🤖 Build Client App APK (Android)
|
||||||
|
script: |
|
||||||
|
make -C ../.. mobile-client-build ENV=$ENV PLATFORM=android BUILD_TYPE=apk
|
||||||
|
|
||||||
|
client-app-ios-build-script: &client-app-ios-build-script
|
||||||
|
name: 👷🍎 Build Client App (iOS)
|
||||||
|
script: |
|
||||||
|
make -C ../.. mobile-client-build ENV=$ENV PLATFORM=ios
|
||||||
|
|
||||||
|
staff-app-android-apk-build-script: &staff-app-android-apk-build-script
|
||||||
|
name: 👷🤖 Build Staff App APK (Android)
|
||||||
|
script: |
|
||||||
|
make -C ../.. mobile-staff-build ENV=$ENV PLATFORM=android BUILD_TYPE=apk
|
||||||
|
|
||||||
|
staff-app-ios-build-script: &staff-app-ios-build-script
|
||||||
|
name: 👷🍎 Build Staff App (iOS)
|
||||||
|
script: |
|
||||||
|
make -C ../.. mobile-staff-build ENV=$ENV PLATFORM=ios
|
||||||
|
|
||||||
|
# Reusable script for distributing Android to Firebase
|
||||||
|
distribute-android-script: &distribute-android-script
|
||||||
|
name: 🚛🤖 Distribute Android to Firebase App Distribution
|
||||||
|
script: |
|
||||||
|
# Distribute Android APK
|
||||||
|
firebase appdistribution:distribute "build/app/outputs/flutter-apk/app-${ENV}-release.apk" \
|
||||||
|
--app $FIREBASE_APP_ID_ANDROID \
|
||||||
|
--release-notes "Build $FCI_BUILD_NUMBER - Environment: $ENV" \
|
||||||
|
--groups "$FIREBASE_TESTER_GROUPS" \
|
||||||
|
--token $FIREBASE_TOKEN
|
||||||
|
|
||||||
|
# Reusable script for distributing iOS to Firebase
|
||||||
|
distribute-ios-script: &distribute-ios-script
|
||||||
|
name: 🚛🍎 Distribute iOS to Firebase App Distribution
|
||||||
|
script: |
|
||||||
|
# Distribute iOS
|
||||||
|
firebase appdistribution:distribute "build/ios/ipa/app.ipa" \
|
||||||
|
--app $FIREBASE_APP_ID_IOS \
|
||||||
|
--release-notes "Build $FCI_BUILD_NUMBER - Environment: $ENV" \
|
||||||
|
--groups "$FIREBASE_TESTER_GROUPS" \
|
||||||
|
--token $FIREBASE_TOKEN
|
||||||
|
|
||||||
|
workflows:
|
||||||
|
# =================================================================================
|
||||||
|
# Base workflow for client_app
|
||||||
|
# =================================================================================
|
||||||
|
client-app-base: &client-app-base
|
||||||
|
name: Client App Base
|
||||||
|
working_directory: mobile-apps/client-app
|
||||||
|
instance_type: mac_mini_m2
|
||||||
|
max_build_duration: 60
|
||||||
|
environment:
|
||||||
|
flutter: stable
|
||||||
|
xcode: latest
|
||||||
|
cocoapods: default
|
||||||
|
cache:
|
||||||
|
cache_paths:
|
||||||
|
- $HOME/.pub-cache
|
||||||
|
- $FCI_BUILD_DIR/mobile-apps/client-app/build
|
||||||
|
- $FCI_BUILD_DIR/mobile-apps/client-app/.dart_tool
|
||||||
|
|
||||||
|
# =================================================================================
|
||||||
|
# Base workflow for staff_app
|
||||||
|
# =================================================================================
|
||||||
|
staff-app-base: &staff-app-base
|
||||||
|
name: Staff App Base
|
||||||
|
working_directory: mobile-apps/staff-app
|
||||||
|
instance_type: mac_mini_m2
|
||||||
|
max_build_duration: 60
|
||||||
|
environment:
|
||||||
|
flutter: stable
|
||||||
|
xcode: latest
|
||||||
|
cocoapods: default
|
||||||
|
cache:
|
||||||
|
cache_paths:
|
||||||
|
- $HOME/.pub-cache
|
||||||
|
- $FCI_BUILD_DIR/mobile-apps/staff-app/build
|
||||||
|
- $FCI_BUILD_DIR/mobile-apps/staff-app/.dart_tool
|
||||||
|
|
||||||
|
# =================================================================================
|
||||||
|
# Client App Workflows - Android
|
||||||
|
# =================================================================================
|
||||||
|
client-app-dev-android:
|
||||||
|
<<: *client-app-base
|
||||||
|
name: 🚛🤖 Client App Dev (Android App Distribution)
|
||||||
|
environment:
|
||||||
|
groups:
|
||||||
|
- client_app_dev_credentials
|
||||||
|
vars:
|
||||||
|
ENV: dev
|
||||||
|
scripts:
|
||||||
|
- *client-app-android-apk-build-script
|
||||||
|
- *distribute-android-script
|
||||||
|
|
||||||
|
client-app-staging-android:
|
||||||
|
<<: *client-app-base
|
||||||
|
name: 🚛🤖 Client App Staging (Android App Distribution)
|
||||||
|
environment:
|
||||||
|
groups:
|
||||||
|
- client_app_staging_credentials
|
||||||
|
vars:
|
||||||
|
ENV: staging
|
||||||
|
scripts:
|
||||||
|
- *client-app-android-apk-build-script
|
||||||
|
- *distribute-android-script
|
||||||
|
|
||||||
|
client-app-prod-android:
|
||||||
|
<<: *client-app-base
|
||||||
|
name: 🚛🤖 Client App Prod (Android App Distribution)
|
||||||
|
environment:
|
||||||
|
groups:
|
||||||
|
- client_app_prod_credentials
|
||||||
|
vars:
|
||||||
|
ENV: prod
|
||||||
|
scripts:
|
||||||
|
- *client-app-android-apk-build-script
|
||||||
|
- *distribute-android-script
|
||||||
|
|
||||||
|
# =================================================================================
|
||||||
|
# Client App Workflows - iOS
|
||||||
|
# =================================================================================
|
||||||
|
client-app-dev-ios:
|
||||||
|
<<: *client-app-base
|
||||||
|
name: 🚛🍎 Client App Dev (iOS App Distribution)
|
||||||
|
environment:
|
||||||
|
groups:
|
||||||
|
- client_app_dev_credentials
|
||||||
|
vars:
|
||||||
|
ENV: dev
|
||||||
|
scripts:
|
||||||
|
- *client-app-ios-build-script
|
||||||
|
- *distribute-ios-script
|
||||||
|
|
||||||
|
client-app-staging-ios:
|
||||||
|
<<: *client-app-base
|
||||||
|
name: 🚛🍎 Client App Staging (iOS App Distribution)
|
||||||
|
environment:
|
||||||
|
groups:
|
||||||
|
- client_app_staging_credentials
|
||||||
|
vars:
|
||||||
|
ENV: staging
|
||||||
|
scripts:
|
||||||
|
- *client-app-ios-build-script
|
||||||
|
- *distribute-ios-script
|
||||||
|
|
||||||
|
client-app-prod-ios:
|
||||||
|
<<: *client-app-base
|
||||||
|
name: 🚛🍎 Client App Prod (iOS App Distribution)
|
||||||
|
environment:
|
||||||
|
groups:
|
||||||
|
- client_app_prod_credentials
|
||||||
|
vars:
|
||||||
|
ENV: prod
|
||||||
|
scripts:
|
||||||
|
- *client-app-ios-build-script
|
||||||
|
- *distribute-ios-script
|
||||||
|
|
||||||
|
# =================================================================================
|
||||||
|
# Staff App Workflows - Android
|
||||||
|
# =================================================================================
|
||||||
|
staff-app-dev-android:
|
||||||
|
<<: *staff-app-base
|
||||||
|
name: 🚛🤖👨🍳 Staff App Dev (Android App Distribution)
|
||||||
|
environment:
|
||||||
|
groups:
|
||||||
|
- staff_app_dev_credentials
|
||||||
|
vars:
|
||||||
|
ENV: dev
|
||||||
|
scripts:
|
||||||
|
- *staff-app-android-apk-build-script
|
||||||
|
- *distribute-android-script
|
||||||
|
|
||||||
|
staff-app-staging-android:
|
||||||
|
<<: *staff-app-base
|
||||||
|
name: 🚛🤖👨🍳 Staff App Staging (Android App Distribution)
|
||||||
|
environment:
|
||||||
|
groups:
|
||||||
|
- staff_app_staging_credentials
|
||||||
|
vars:
|
||||||
|
ENV: staging
|
||||||
|
scripts:
|
||||||
|
- *staff-app-android-apk-build-script
|
||||||
|
- *distribute-android-script
|
||||||
|
|
||||||
|
staff-app-prod-android:
|
||||||
|
<<: *staff-app-base
|
||||||
|
name: 🚛🤖👨🍳 Staff App Prod (Android App Distribution)
|
||||||
|
environment:
|
||||||
|
groups:
|
||||||
|
- staff_app_prod_credentials
|
||||||
|
vars:
|
||||||
|
ENV: prod
|
||||||
|
scripts:
|
||||||
|
- *staff-app-android-apk-build-script
|
||||||
|
- *distribute-android-script
|
||||||
|
|
||||||
|
# =================================================================================
|
||||||
|
# Staff App Workflows - iOS
|
||||||
|
# =================================================================================
|
||||||
|
staff-app-dev-ios:
|
||||||
|
<<: *staff-app-base
|
||||||
|
name: 🚛🍎👨🍳 Staff App Dev (iOS App Distribution)
|
||||||
|
environment:
|
||||||
|
groups:
|
||||||
|
- staff_app_dev_credentials
|
||||||
|
vars:
|
||||||
|
ENV: dev
|
||||||
|
scripts:
|
||||||
|
- *staff-app-ios-build-script
|
||||||
|
- *distribute-ios-script
|
||||||
|
|
||||||
|
staff-app-staging-ios:
|
||||||
|
<<: *staff-app-base
|
||||||
|
name: 🚛🍎👨🍳 Staff App Staging (iOS App Distribution)
|
||||||
|
environment:
|
||||||
|
groups:
|
||||||
|
- staff_app_staging_credentials
|
||||||
|
vars:
|
||||||
|
ENV: staging
|
||||||
|
scripts:
|
||||||
|
- *staff-app-ios-build-script
|
||||||
|
- *distribute-ios-script
|
||||||
|
|
||||||
|
staff-app-prod-ios:
|
||||||
|
<<: *staff-app-base
|
||||||
|
name: 🚛🍎👨🍳 Staff App Prod (iOS App Distribution)
|
||||||
|
environment:
|
||||||
|
groups:
|
||||||
|
- staff_app_prod_credentials
|
||||||
|
vars:
|
||||||
|
ENV: prod
|
||||||
|
scripts:
|
||||||
|
- *staff-app-ios-build-script
|
||||||
|
- *distribute-ios-script
|
||||||
|
|
||||||
34
docs/prompts/create-codemagic-monorepo.md
Normal file
34
docs/prompts/create-codemagic-monorepo.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
Looking at the monorepo containing two separate Flutter applications in
|
||||||
|
- mobile-apps/client-app
|
||||||
|
- mobile-apps/staff-app
|
||||||
|
, and I want to configure Codemagic so both apps can be built and distributed to Firebase App Distribution.
|
||||||
|
|
||||||
|
Please do the following:
|
||||||
|
1. propose the best layout for Codemagic workflows.
|
||||||
|
2. Create three separate pipelines for each application:
|
||||||
|
* Development (dev)
|
||||||
|
* Staging (stage)
|
||||||
|
* Production (prod)
|
||||||
|
3. Each pipeline must:
|
||||||
|
* Build the correct Flutter app inside the monorepo.
|
||||||
|
* Use the correct Firebase App Distribution credentials for each environment.
|
||||||
|
* Push the built artifacts (Android + iOS if applicable) to the appropriate Firebase App Distribution app.
|
||||||
|
* Include environment-specific values (e.g., env variables, bundle IDs, keystore/certs, build flavors).
|
||||||
|
* Allow triggering pipelines manually.
|
||||||
|
4. Generate a complete codemagic.yaml example with:
|
||||||
|
* Separate workflows for:
|
||||||
|
* `client_app_dev`, `client_app_staging`, `client_app_prod`
|
||||||
|
* `client_app_dev`, `client_app_staging`, `client_app_prod`
|
||||||
|
* All required steps (install Flutter/Java/Xcode, pub get, build runner if needed, building APK/AAB/IPA, uploading to Firebase App Distribution, etc.).
|
||||||
|
* Example Firebase App IDs, release notes, tester groups, service accounts.
|
||||||
|
* Proper use of Codemagic encrypted variables.
|
||||||
|
* Best practices for monorepo path handling.
|
||||||
|
5. Add a short explanation of:
|
||||||
|
* How each pipeline works
|
||||||
|
* How to trigger builds
|
||||||
|
* How to update environment variables for Firebase
|
||||||
|
6. Output the final result as:
|
||||||
|
* A complete `codemagic.yaml`
|
||||||
|
* A brief guide on integrating it with a monorepo
|
||||||
|
* Notes on debugging, caching, and CI/CD optimization
|
||||||
|
|
||||||
@@ -44,6 +44,38 @@
|
|||||||
"destination": "/index.html"
|
"destination": "/index.html"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "api-harness-dev",
|
||||||
|
"public": "internal-api-harness/dist",
|
||||||
|
"site": "krow-api-harness-dev",
|
||||||
|
"ignore": [
|
||||||
|
"firebase.json",
|
||||||
|
"**/.*",
|
||||||
|
"**/node_modules/**"
|
||||||
|
],
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "**",
|
||||||
|
"destination": "/index.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "api-harness-staging",
|
||||||
|
"public": "internal-api-harness/dist",
|
||||||
|
"site": "krow-api-harness-staging",
|
||||||
|
"ignore": [
|
||||||
|
"firebase.json",
|
||||||
|
"**/.*",
|
||||||
|
"**/node_modules/**"
|
||||||
|
],
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "**",
|
||||||
|
"destination": "/index.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"emulators": {
|
"emulators": {
|
||||||
|
|||||||
@@ -93,6 +93,35 @@
|
|||||||
#diagram-container:active {
|
#diagram-container:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Accordion styles */
|
||||||
|
.accordion-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #4b5563; /* text-gray-600 */
|
||||||
|
text-align: left;
|
||||||
|
background-color: #f9fafb; /* bg-gray-50 */
|
||||||
|
border-radius: 0.5rem; /* rounded-lg */
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.accordion-button:hover {
|
||||||
|
background-color: #f3f4f6; /* bg-gray-100 */
|
||||||
|
}
|
||||||
|
.accordion-button .chevron {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.accordion-button[aria-expanded="true"] .chevron {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.accordion-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease-out;
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Modal styles */
|
/* Modal styles */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
@@ -566,150 +595,96 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Build hierarchical structure from paths
|
// Build hierarchical structure from paths
|
||||||
function buildDiagramHierarchy(diagrams) {
|
function buildHierarchy(items, pathPrefix) {
|
||||||
const hierarchy = {};
|
const hierarchy = { _root: { _items: [], _children: {} } };
|
||||||
|
|
||||||
diagrams.forEach(diagram => {
|
items.forEach(item => {
|
||||||
const parts = diagram.path.split('/');
|
let relativePath = item.path;
|
||||||
const relevantParts = parts.slice(2, -1); // Remove 'assets/diagrams/' and filename
|
if (relativePath.startsWith('./')) {
|
||||||
|
relativePath = relativePath.substring(2);
|
||||||
let current = hierarchy;
|
|
||||||
relevantParts.forEach(part => {
|
|
||||||
if (!current[part]) {
|
|
||||||
current[part] = { _items: [], _children: {} };
|
|
||||||
}
|
|
||||||
current = current[part]._children;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add the item to appropriate level
|
|
||||||
if (relevantParts.length > 0) {
|
|
||||||
let parent = hierarchy[relevantParts[0]];
|
|
||||||
for (let i = 1; i < relevantParts.length; i++) {
|
|
||||||
parent = parent._children[relevantParts[i]];
|
|
||||||
}
|
|
||||||
parent._items.push(diagram);
|
|
||||||
} else {
|
|
||||||
// Root level diagrams
|
|
||||||
if (!hierarchy._root) {
|
|
||||||
hierarchy._root = { _items: [], _children: {} };
|
|
||||||
}
|
|
||||||
hierarchy._root._items.push(diagram);
|
|
||||||
}
|
}
|
||||||
|
if (relativePath.startsWith(pathPrefix)) {
|
||||||
|
relativePath = relativePath.substring(pathPrefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = relativePath.split('/');
|
||||||
|
const relevantParts = parts.slice(0, -1); // remove filename
|
||||||
|
|
||||||
|
let current = hierarchy._root;
|
||||||
|
relevantParts.forEach(part => {
|
||||||
|
if (!current._children[part]) {
|
||||||
|
current._children[part] = { _items: [], _children: {} };
|
||||||
|
}
|
||||||
|
current = current._children[part];
|
||||||
|
});
|
||||||
|
current._items.push(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
return hierarchy;
|
return hierarchy;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build hierarchical structure from paths (for documents)
|
// Generic function to create accordion navigation
|
||||||
function buildDocumentHierarchy(documents) {
|
function createAccordionNavigation(hierarchy, parentElement, createLinkFunction, sectionTitle) {
|
||||||
const hierarchy = {};
|
const createAccordion = (title, items, children) => {
|
||||||
|
const container = document.createElement('div');
|
||||||
documents.forEach(doc => {
|
container.className = 'mb-1';
|
||||||
const parts = doc.path.split('/');
|
|
||||||
const relevantParts = parts.slice(2, -1); // Remove 'assets/documents/' and filename
|
|
||||||
|
|
||||||
let current = hierarchy;
|
|
||||||
relevantParts.forEach(part => {
|
|
||||||
if (!current[part]) {
|
|
||||||
current[part] = { _items: [], _children: {} };
|
|
||||||
}
|
|
||||||
current = current[part]._children;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add the item to appropriate level
|
|
||||||
if (relevantParts.length > 0) {
|
|
||||||
let parent = hierarchy[relevantParts[0]];
|
|
||||||
for (let i = 1; i < relevantParts.length; i++) {
|
|
||||||
parent = parent._children[relevantParts[i]];
|
|
||||||
}
|
|
||||||
parent._items.push(doc);
|
|
||||||
} else {
|
|
||||||
// Root level documents
|
|
||||||
if (!hierarchy._root) {
|
|
||||||
hierarchy._root = { _items: [], _children: {} };
|
|
||||||
}
|
|
||||||
hierarchy._root._items.push(doc);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return hierarchy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create navigation from hierarchy
|
const button = document.createElement('button');
|
||||||
function createNavigation(hierarchy, parentElement, level = 0) {
|
button.className = 'accordion-button';
|
||||||
// First, show root level items if any
|
button.setAttribute('aria-expanded', 'false');
|
||||||
if (hierarchy._root && hierarchy._root._items.length > 0) {
|
button.innerHTML = `
|
||||||
const mainHeading = document.createElement('div');
|
<span class="font-medium">${title.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}</span>
|
||||||
mainHeading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mt-6 mb-3';
|
<svg class="chevron w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||||
mainHeading.textContent = 'Diagrams';
|
`;
|
||||||
parentElement.appendChild(mainHeading);
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'accordion-panel pl-4 pt-1';
|
||||||
|
|
||||||
hierarchy._root._items.forEach(diagram => {
|
if (items) {
|
||||||
createDiagramLink(diagram, parentElement, 0);
|
items.forEach(item => createLinkFunction(item, panel, 1));
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
if (children) {
|
||||||
// Then process nested categories
|
Object.keys(children).forEach(childKey => {
|
||||||
Object.keys(hierarchy).forEach(key => {
|
const childSection = children[childKey];
|
||||||
if (key === '_items' || key === '_children' || key === '_root') return;
|
const childHeading = document.createElement('div');
|
||||||
|
childHeading.className = 'px-4 pt-2 pb-1 text-xs font-semibold text-gray-400 uppercase tracking-wider';
|
||||||
const section = hierarchy[key];
|
childHeading.textContent = childKey.replace(/-/g, ' ');
|
||||||
const heading = document.createElement('div');
|
panel.appendChild(childHeading);
|
||||||
heading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider ' +
|
childSection._items.forEach(item => createLinkFunction(item, panel, 2));
|
||||||
(level === 0 ? 'mt-6 mb-3' : 'mt-4 mb-2 pl-8');
|
|
||||||
heading.textContent = key.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
||||||
parentElement.appendChild(heading);
|
|
||||||
|
|
||||||
// Add items in this section
|
|
||||||
if (section._items && section._items.length > 0) {
|
|
||||||
section._items.forEach(diagram => {
|
|
||||||
createDiagramLink(diagram, parentElement, level);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursively add children
|
|
||||||
if (section._children && Object.keys(section._children).length > 0) {
|
|
||||||
createNavigation(section._children, parentElement, level + 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create document navigation from hierarchy
|
button.addEventListener('click', () => {
|
||||||
function createDocumentNavigation(hierarchy, parentElement, level = 0) {
|
const isExpanded = button.getAttribute('aria-expanded') === 'true';
|
||||||
// First, show root level items if any
|
button.setAttribute('aria-expanded', !isExpanded);
|
||||||
if (hierarchy._root && hierarchy._root._items.length > 0) {
|
if (!isExpanded) {
|
||||||
const mainHeading = document.createElement('div');
|
panel.style.maxHeight = panel.scrollHeight + 'px';
|
||||||
mainHeading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mt-6 mb-3';
|
} else {
|
||||||
mainHeading.textContent = 'Documentation';
|
panel.style.maxHeight = '0px';
|
||||||
parentElement.appendChild(mainHeading);
|
}
|
||||||
|
|
||||||
hierarchy._root._items.forEach(doc => {
|
|
||||||
createDocumentLink(doc, parentElement, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
container.appendChild(button);
|
||||||
|
container.appendChild(panel);
|
||||||
|
return container;
|
||||||
|
};
|
||||||
|
|
||||||
|
const heading = document.createElement('div');
|
||||||
|
heading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mt-6 mb-3';
|
||||||
|
heading.textContent = sectionTitle;
|
||||||
|
parentElement.appendChild(heading);
|
||||||
|
|
||||||
|
// Process root items first
|
||||||
|
if (hierarchy._root && hierarchy._root._items.length > 0) {
|
||||||
|
hierarchy._root._items.forEach(item => createLinkFunction(item, parentElement, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then process nested categories
|
// Process categories as accordions
|
||||||
Object.keys(hierarchy).forEach(key => {
|
Object.keys(hierarchy._root._children).forEach(key => {
|
||||||
if (key === '_items' || key === '_children' || key === '_root') return;
|
if (key.startsWith('_')) return;
|
||||||
|
const section = hierarchy._root._children[key];
|
||||||
const section = hierarchy[key];
|
const accordion = createAccordion(key, section._items, section._children);
|
||||||
const heading = document.createElement('div');
|
parentElement.appendChild(accordion);
|
||||||
heading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider ' +
|
|
||||||
(level === 0 ? 'mt-6 mb-3' : 'mt-4 mb-2 pl-8');
|
|
||||||
heading.textContent = key.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
||||||
parentElement.appendChild(heading);
|
|
||||||
|
|
||||||
// Add items in this section
|
|
||||||
if (section._items && section._items.length > 0) {
|
|
||||||
section._items.forEach(doc => {
|
|
||||||
createDocumentLink(doc, parentElement, level);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively add children
|
|
||||||
if (section._children && Object.keys(section._children).length > 0) {
|
|
||||||
createDocumentNavigation(section._children, parentElement, level + 1);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,8 +692,8 @@
|
|||||||
function createDocumentLink(doc, parentElement, level) {
|
function createDocumentLink(doc, parentElement, level) {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = '#';
|
link.href = '#';
|
||||||
link.className = 'nav-item flex items-center space-x-3 px-4 py-3 rounded-xl text-gray-700 hover:bg-gray-50 mb-1' +
|
link.className = 'nav-item flex items-center space-x-3 px-4 py-2.5 rounded-lg text-gray-600 hover:bg-gray-100 text-sm mb-1' +
|
||||||
(level > 0 ? ' pl-8' : '');
|
(level > 0 ? ' ' : '');
|
||||||
link.onclick = (e) => {
|
link.onclick = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showView('document', link, doc.path, doc.title);
|
showView('document', link, doc.path, doc.title);
|
||||||
@@ -728,7 +703,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
link.innerHTML = `${iconSvg}<span class="text-sm">${doc.title}</span>`;
|
link.innerHTML = `${iconSvg}<span class="truncate">${doc.title}</span>`;
|
||||||
parentElement.appendChild(link);
|
parentElement.appendChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,26 +711,18 @@
|
|||||||
function createDiagramLink(diagram, parentElement, level) {
|
function createDiagramLink(diagram, parentElement, level) {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = '#';
|
link.href = '#';
|
||||||
link.className = 'nav-item flex items-center space-x-3 px-4 py-3 rounded-xl text-gray-700 hover:bg-gray-50 mb-1' +
|
link.className = 'nav-item flex items-center space-x-3 px-4 py-2.5 rounded-lg text-gray-600 hover:bg-gray-100 text-sm mb-1' +
|
||||||
(level > 0 ? ' pl-8' : '');
|
(level > 0 ? ' ' : '');
|
||||||
link.onclick = (e) => {
|
link.onclick = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showView('diagram', link, diagram.path, diagram.title, diagram.type);
|
showView('diagram', link, diagram.path, diagram.title, diagram.type);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get icon based on type or custom icon
|
const iconSvg = `<svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
let iconSvg = '';
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4l2 2h4a2 2 0 012 2v12a2 2 0 01-2 2h-4l-2-2H7z"></path>
|
||||||
if (diagram.type === 'svg') {
|
</svg>`;
|
||||||
iconSvg = `<svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
|
||||||
</svg>`;
|
|
||||||
} else {
|
|
||||||
iconSvg = `<svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
link.innerHTML = iconSvg + '<span class="text-sm">' + diagram.title + '</span>';
|
link.innerHTML = `${iconSvg}<span class="truncate">${diagram.title}</span>`;
|
||||||
parentElement.appendChild(link);
|
parentElement.appendChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,12 +737,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
console.log('Loaded config:', text);
|
|
||||||
allDiagrams = JSON.parse(text);
|
allDiagrams = JSON.parse(text);
|
||||||
|
|
||||||
if (allDiagrams && allDiagrams.length > 0) {
|
if (allDiagrams && allDiagrams.length > 0) {
|
||||||
const hierarchy = buildDiagramHierarchy(allDiagrams);
|
const hierarchy = buildHierarchy(allDiagrams, 'assets/diagrams/');
|
||||||
createNavigation(hierarchy, dynamicSection);
|
createAccordionNavigation(hierarchy, dynamicSection, createDiagramLink, 'Diagrams');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading diagrams configuration:', error);
|
console.error('Error loading diagrams configuration:', error);
|
||||||
@@ -801,12 +767,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
console.log('Loaded documents config:', text);
|
|
||||||
allDocuments = JSON.parse(text);
|
allDocuments = JSON.parse(text);
|
||||||
|
|
||||||
if (allDocuments && allDocuments.length > 0) {
|
if (allDocuments && allDocuments.length > 0) {
|
||||||
const hierarchy = buildDocumentHierarchy(allDocuments);
|
const hierarchy = buildHierarchy(allDocuments, 'assets/documents/');
|
||||||
createDocumentNavigation(hierarchy, documentationSection);
|
createAccordionNavigation(hierarchy, documentationSection, createDocumentLink, 'Documentation');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading documents configuration:', error);
|
console.error('Error loading documents configuration:', error);
|
||||||
|
|||||||
68
frontend-web/package-lock.json
generated
68
frontend-web/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base44/sdk": "^0.1.2",
|
"@base44/sdk": "^0.1.2",
|
||||||
"@dataconnect/generated": "file:src/dataconnect-generated",
|
"@dataconnect/generated": "file:src/dataconnect-generated",
|
||||||
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@hookform/resolvers": "^4.1.2",
|
"@hookform/resolvers": "^4.1.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.3",
|
"@radix-ui/react-accordion": "^1.2.3",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
@@ -1740,6 +1741,23 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@hello-pangea/dnd": {
|
||||||
|
"version": "18.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz",
|
||||||
|
"integrity": "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.26.7",
|
||||||
|
"css-box-model": "^1.2.1",
|
||||||
|
"raf-schd": "^4.0.3",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
|
"redux": "^5.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hookform/resolvers": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.3.tgz",
|
||||||
@@ -4491,6 +4509,12 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -5274,6 +5298,15 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-box-model": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tiny-invariant": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -8375,6 +8408,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/raf-schd": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -8436,6 +8475,29 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -8653,6 +8715,12 @@
|
|||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base44/sdk": "^0.1.2",
|
"@base44/sdk": "^0.1.2",
|
||||||
"@dataconnect/generated": "file:src/dataconnect-generated",
|
"@dataconnect/generated": "file:src/dataconnect-generated",
|
||||||
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@hookform/resolvers": "^4.1.2",
|
"@hookform/resolvers": "^4.1.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.3",
|
"@radix-ui/react-accordion": "^1.2.3",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ export const TeamMemberInvite = base44.entities.TeamMemberInvite;
|
|||||||
|
|
||||||
export const VendorDocumentReview = base44.entities.VendorDocumentReview;
|
export const VendorDocumentReview = base44.entities.VendorDocumentReview;
|
||||||
|
|
||||||
|
export const Task = base44.entities.Task;
|
||||||
|
|
||||||
|
export const TaskComment = base44.entities.TaskComment;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// auth sdk:
|
// auth sdk:
|
||||||
|
|||||||
396
frontend-web/src/components/dashboard/DashboardCustomizer.jsx
Normal file
396
frontend-web/src/components/dashboard/DashboardCustomizer.jsx
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
GripVertical,
|
||||||
|
X,
|
||||||
|
Plus,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Info,
|
||||||
|
Save,
|
||||||
|
RotateCcw,
|
||||||
|
Sparkles
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
export default function DashboardCustomizer({
|
||||||
|
user,
|
||||||
|
availableWidgets = [],
|
||||||
|
currentLayout = [],
|
||||||
|
onLayoutChange,
|
||||||
|
dashboardType = "default" // admin, client, vendor, operator, etc
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [showHowItWorks, setShowHowItWorks] = useState(false);
|
||||||
|
const [visibleWidgets, setVisibleWidgets] = useState([]);
|
||||||
|
const [hiddenWidgets, setHiddenWidgets] = useState([]);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Initialize widgets from user's saved layout or defaults
|
||||||
|
useEffect(() => {
|
||||||
|
const layoutKey = `dashboard_layout_${dashboardType}`;
|
||||||
|
const savedLayout = user?.[layoutKey];
|
||||||
|
|
||||||
|
if (savedLayout?.widgets && savedLayout.widgets.length > 0) {
|
||||||
|
const savedVisible = savedLayout.widgets
|
||||||
|
.map(id => availableWidgets.find(w => w.id === id))
|
||||||
|
.filter(Boolean);
|
||||||
|
setVisibleWidgets(savedVisible);
|
||||||
|
|
||||||
|
const savedHidden = savedLayout.hidden_widgets || [];
|
||||||
|
const hiddenWidgetsList = availableWidgets.filter(w =>
|
||||||
|
savedHidden.includes(w.id)
|
||||||
|
);
|
||||||
|
setHiddenWidgets(hiddenWidgetsList);
|
||||||
|
} else {
|
||||||
|
// Default: all widgets visible in provided order
|
||||||
|
setVisibleWidgets(availableWidgets);
|
||||||
|
setHiddenWidgets([]);
|
||||||
|
}
|
||||||
|
}, [user, availableWidgets, isOpen, dashboardType]);
|
||||||
|
|
||||||
|
// Save layout mutation
|
||||||
|
const saveLayoutMutation = useMutation({
|
||||||
|
mutationFn: async (layoutData) => {
|
||||||
|
const layoutKey = `dashboard_layout_${dashboardType}`;
|
||||||
|
await base44.auth.updateMe({
|
||||||
|
[layoutKey]: layoutData
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user-layout'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user-client'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user-vendor'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user-operator'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Layout Saved",
|
||||||
|
description: "Your dashboard layout has been updated",
|
||||||
|
});
|
||||||
|
setHasChanges(false);
|
||||||
|
if (onLayoutChange) {
|
||||||
|
onLayoutChange(visibleWidgets);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: "❌ Save Failed",
|
||||||
|
description: "Could not save your layout. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDragEnd = (result) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
|
||||||
|
const { source, destination } = result;
|
||||||
|
|
||||||
|
if (source.droppableId === "visible" && destination.droppableId === "visible") {
|
||||||
|
const items = Array.from(visibleWidgets);
|
||||||
|
const [reorderedItem] = items.splice(source.index, 1);
|
||||||
|
items.splice(destination.index, 0, reorderedItem);
|
||||||
|
setVisibleWidgets(items);
|
||||||
|
setHasChanges(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHideWidget = (widget) => {
|
||||||
|
setVisibleWidgets(visibleWidgets.filter(w => w.id !== widget.id));
|
||||||
|
setHiddenWidgets([...hiddenWidgets, widget]);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowWidget = (widget) => {
|
||||||
|
setHiddenWidgets(hiddenWidgets.filter(w => w.id !== widget.id));
|
||||||
|
setVisibleWidgets([...visibleWidgets, widget]);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const layoutData = {
|
||||||
|
widgets: visibleWidgets.map(w => w.id),
|
||||||
|
hidden_widgets: hiddenWidgets.map(w => w.id),
|
||||||
|
layout_version: "2.0"
|
||||||
|
};
|
||||||
|
saveLayoutMutation.mutate(layoutData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setVisibleWidgets(availableWidgets);
|
||||||
|
setHiddenWidgets([]);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCustomizer = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
setShowHowItWorks(true);
|
||||||
|
setHasChanges(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (hasChanges) {
|
||||||
|
if (window.confirm("You have unsaved changes. Are you sure you want to close?")) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setHasChanges(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Customize Button */}
|
||||||
|
<Button
|
||||||
|
onClick={handleOpenCustomizer}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 border-2 border-blue-200 hover:bg-blue-50 text-blue-600 font-semibold"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
Customize
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Customizer Dialog */}
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Sparkles className="w-6 h-6 text-blue-600" />
|
||||||
|
Customize Your Dashboard
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Personalize your workspace by adding, removing, and reordering widgets
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* How It Works Banner */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showHowItWorks && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-xl p-4 mb-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Info className="w-5 h-5 text-blue-600" />
|
||||||
|
<h3 className="font-bold text-blue-900">How it works</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-blue-800 flex items-center gap-2">
|
||||||
|
<GripVertical className="w-4 h-4" />
|
||||||
|
<strong>Drag</strong> widgets to reorder them
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-blue-800 flex items-center gap-2">
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
<strong>Hide</strong> widgets you don't need
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-blue-800 flex items-center gap-2">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
<strong>Show</strong> hidden widgets to bring them back
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHowItWorks(false)}
|
||||||
|
className="text-blue-400 hover:text-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Visible Widgets */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="font-bold text-lg text-slate-900">
|
||||||
|
Visible Widgets ({visibleWidgets.length})
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
onClick={handleReset}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={saveLayoutMutation.isPending}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Reset to Default
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
|
<Droppable droppableId="visible">
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
{...provided.droppableProps}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
className={`space-y-2 min-h-[100px] p-4 rounded-lg border-2 border-dashed transition-all ${
|
||||||
|
snapshot.isDraggingOver
|
||||||
|
? 'border-blue-400 bg-blue-50'
|
||||||
|
: 'border-slate-200 bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{visibleWidgets.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-slate-400">
|
||||||
|
<Eye className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-sm font-medium">No visible widgets</p>
|
||||||
|
<p className="text-xs mt-1">Add widgets from the hidden section below!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
visibleWidgets.map((widget, index) => (
|
||||||
|
<Draggable key={widget.id} draggableId={widget.id} index={index}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
className={`bg-white border-2 rounded-lg p-4 transition-all ${
|
||||||
|
snapshot.isDragging
|
||||||
|
? 'border-blue-400 shadow-2xl scale-105 rotate-2'
|
||||||
|
: 'border-slate-200 hover:border-blue-300 hover:shadow-md'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
className="cursor-grab active:cursor-grabbing text-slate-400 hover:text-blue-600 transition-colors p-1 hover:bg-blue-50 rounded"
|
||||||
|
>
|
||||||
|
<GripVertical className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-bold text-slate-900">{widget.title}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">{widget.description}</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={`${widget.categoryColor || 'bg-blue-100 text-blue-700'} border-0 text-xs`}>
|
||||||
|
{widget.category}
|
||||||
|
</Badge>
|
||||||
|
<button
|
||||||
|
onClick={() => handleHideWidget(widget)}
|
||||||
|
className="text-slate-400 hover:text-red-600 transition-colors p-2 hover:bg-red-50 rounded-lg"
|
||||||
|
title="Hide widget"
|
||||||
|
disabled={saveLayoutMutation.isPending}
|
||||||
|
>
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden Widgets */}
|
||||||
|
{hiddenWidgets.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg text-slate-900 mb-3 flex items-center gap-2">
|
||||||
|
Hidden Widgets ({hiddenWidgets.length})
|
||||||
|
<Badge className="bg-slate-200 text-slate-600 text-xs">Click + to add</Badge>
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{hiddenWidgets.map((widget) => (
|
||||||
|
<div
|
||||||
|
key={widget.id}
|
||||||
|
className="bg-slate-50 border-2 border-dashed border-slate-300 rounded-lg p-4 opacity-60 hover:opacity-100 transition-all hover:border-green-400 hover:bg-green-50/50 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-semibold text-slate-900">{widget.title}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">{widget.description}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleShowWidget(widget)}
|
||||||
|
className="text-slate-400 hover:text-green-600 group-hover:bg-green-100 transition-colors p-2 rounded-lg"
|
||||||
|
title="Show widget"
|
||||||
|
disabled={saveLayoutMutation.isPending}
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All Hidden Message */}
|
||||||
|
{hiddenWidgets.length === 0 && visibleWidgets.length === availableWidgets.length && (
|
||||||
|
<div className="text-center py-6 bg-green-50 border-2 border-green-200 rounded-lg">
|
||||||
|
<Sparkles className="w-8 h-8 mx-auto mb-2 text-green-600" />
|
||||||
|
<p className="text-sm font-medium text-green-800">
|
||||||
|
All widgets are visible on your dashboard!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t mt-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="text-slate-600"
|
||||||
|
disabled={saveLayoutMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{hasChanges && (
|
||||||
|
<Badge className="bg-orange-500 text-white animate-pulse">
|
||||||
|
Unsaved Changes
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowHowItWorks(!showHowItWorks)}
|
||||||
|
className="gap-2"
|
||||||
|
disabled={saveLayoutMutation.isPending}
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
{showHowItWorks ? 'Hide' : 'Show'} Help
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saveLayoutMutation.isPending || !hasChanges}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{saveLayoutMutation.isPending ? "Saving..." : hasChanges ? "Save Layout" : "No Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
frontend-web/src/components/events/AssignedStaffManager.jsx
Normal file
235
frontend-web/src/components/events/AssignedStaffManager.jsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { Edit2, Trash2, ArrowLeftRight, Clock, MapPin, Check, X } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
export default function AssignedStaffManager({ event, shift, role }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [editTarget, setEditTarget] = useState(null);
|
||||||
|
const [swapTarget, setSwapTarget] = useState(null);
|
||||||
|
const [editTimes, setEditTimes] = useState({ start: "", end: "" });
|
||||||
|
|
||||||
|
// Get assigned staff for this role
|
||||||
|
const assignedStaff = (event.assigned_staff || []).filter(
|
||||||
|
s => s.role === role?.role
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove staff mutation
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: async (staffId) => {
|
||||||
|
const updatedAssignedStaff = (event.assigned_staff || []).filter(
|
||||||
|
s => s.staff_id !== staffId || s.role !== role.role
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedShifts = (event.shifts || []).map(s => {
|
||||||
|
if (s.shift_name === shift.shift_name) {
|
||||||
|
const updatedRoles = (s.roles || []).map(r => {
|
||||||
|
if (r.role === role.role) {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
assigned: Math.max((r.assigned || 0) - 1, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
return { ...s, roles: updatedRoles };
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
|
||||||
|
await base44.entities.Event.update(event.id, {
|
||||||
|
assigned_staff: updatedAssignedStaff,
|
||||||
|
shifts: updatedShifts,
|
||||||
|
requested: Math.max((event.requested || 0) - 1, 0),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Staff Removed",
|
||||||
|
description: "Staff member has been removed from this assignment",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit times mutation
|
||||||
|
const editMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const updatedShifts = (event.shifts || []).map(s => {
|
||||||
|
if (s.shift_name === shift.shift_name) {
|
||||||
|
const updatedRoles = (s.roles || []).map(r => {
|
||||||
|
if (r.role === role.role) {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
start_time: editTimes.start,
|
||||||
|
end_time: editTimes.end,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
return { ...s, roles: updatedRoles };
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
|
||||||
|
await base44.entities.Event.update(event.id, {
|
||||||
|
shifts: updatedShifts,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Times Updated",
|
||||||
|
description: "Assignment times have been updated",
|
||||||
|
});
|
||||||
|
setEditTarget(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = (staff) => {
|
||||||
|
setEditTarget(staff);
|
||||||
|
setEditTimes({
|
||||||
|
start: role.start_time || "09:00",
|
||||||
|
end: role.end_time || "17:00",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = () => {
|
||||||
|
editMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (staffId) => {
|
||||||
|
if (confirm("Are you sure you want to remove this staff member?")) {
|
||||||
|
removeMutation.mutate(staffId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!assignedStaff.length) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-6 text-slate-500 text-sm">
|
||||||
|
No staff assigned yet
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{assignedStaff.map((staff) => (
|
||||||
|
<div
|
||||||
|
key={staff.staff_id}
|
||||||
|
className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-lg hover:shadow-sm transition-shadow"
|
||||||
|
>
|
||||||
|
<Avatar className="w-10 h-10 bg-gradient-to-br from-green-600 to-emerald-600">
|
||||||
|
<AvatarFallback className="text-white font-bold">
|
||||||
|
{staff.staff_name?.charAt(0) || 'S'}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-slate-900">{staff.staff_name}</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-600">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{staff.role}
|
||||||
|
</Badge>
|
||||||
|
{role.start_time && role.end_time && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{role.start_time} - {role.end_time}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEdit(staff)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
title="Edit times"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4 text-slate-600" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemove(staff.staff_id)}
|
||||||
|
disabled={removeMutation.isPending}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Times Dialog */}
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Assignment Times</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Staff Member</Label>
|
||||||
|
<p className="text-sm font-medium mt-1">{editTarget?.staff_name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Start Time</Label>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={editTimes.start}
|
||||||
|
onChange={(e) => setEditTimes({ ...editTimes, start: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>End Time</Label>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={editTimes.end}
|
||||||
|
onChange={(e) => setEditTimes({ ...editTimes, end: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditTarget(null)}>
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveEdit}
|
||||||
|
disabled={editMutation.isPending}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 mr-2" />
|
||||||
|
{editMutation.isPending ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
date: "",
|
date: "",
|
||||||
include_backup: false,
|
include_backup: false,
|
||||||
backup_staff_count: 0,
|
backup_staff_count: 0,
|
||||||
|
vendor_id: "", // Added vendor_id
|
||||||
|
vendor_name: "", // Added vendor_name
|
||||||
shifts: [{
|
shifts: [{
|
||||||
shift_name: "Shift 1",
|
shift_name: "Shift 1",
|
||||||
location_address: "",
|
location_address: "",
|
||||||
@@ -72,6 +74,7 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
const currentUserData = currentUser || user;
|
const currentUserData = currentUser || user;
|
||||||
const userRole = currentUserData?.user_role || currentUserData?.role || "admin";
|
const userRole = currentUserData?.user_role || currentUserData?.role || "admin";
|
||||||
const isVendor = userRole === "vendor";
|
const isVendor = userRole === "vendor";
|
||||||
|
const isClient = userRole === "client"; // Added isClient
|
||||||
|
|
||||||
const { data: businesses = [] } = useQuery({
|
const { data: businesses = [] } = useQuery({
|
||||||
queryKey: ['businesses'],
|
queryKey: ['businesses'],
|
||||||
@@ -79,6 +82,12 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
initialData: [],
|
initialData: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: vendors = [] } = useQuery({ // Added vendors query
|
||||||
|
queryKey: ['vendors-for-order'],
|
||||||
|
queryFn: () => base44.entities.Vendor.list(),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
const { data: allRates = [] } = useQuery({
|
const { data: allRates = [] } = useQuery({
|
||||||
queryKey: ['vendor-rates-all'],
|
queryKey: ['vendor-rates-all'],
|
||||||
queryFn: () => base44.entities.VendorRate.list(),
|
queryFn: () => base44.entities.VendorRate.list(),
|
||||||
@@ -87,6 +96,22 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
|
|
||||||
const availableRoles = [...new Set(allRates.map(r => r.role_name))].sort();
|
const availableRoles = [...new Set(allRates.map(r => r.role_name))].sort();
|
||||||
|
|
||||||
|
// Auto-select preferred vendor for clients
|
||||||
|
useEffect(() => {
|
||||||
|
if (isClient && currentUserData && !formData.vendor_id) {
|
||||||
|
const preferredVendorId = currentUserData.preferred_vendor_id;
|
||||||
|
const preferredVendorName = currentUserData.preferred_vendor_name;
|
||||||
|
|
||||||
|
if (preferredVendorId) {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
vendor_id: preferredVendorId,
|
||||||
|
vendor_name: preferredVendorName || ""
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isClient, currentUserData, formData.vendor_id]); // Dependency array updated
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (event) {
|
if (event) {
|
||||||
setFormData(event);
|
setFormData(event);
|
||||||
@@ -112,6 +137,38 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVendorChange = (vendorId) => { // Added handleVendorChange
|
||||||
|
const selectedVendor = vendors.find(v => v.id === vendorId);
|
||||||
|
if (selectedVendor) {
|
||||||
|
setFormData(prev => {
|
||||||
|
const updatedShifts = prev.shifts.map(shift => ({
|
||||||
|
...shift,
|
||||||
|
roles: shift.roles.map(role => {
|
||||||
|
if (role.role) {
|
||||||
|
const rate = getRateForRole(role.role, vendorId); // Re-calculate rates with new vendorId
|
||||||
|
return {
|
||||||
|
...role,
|
||||||
|
rate_per_hour: rate,
|
||||||
|
total_value: rate * (role.hours || 0) * (role.count || 1),
|
||||||
|
vendor_id: vendorId,
|
||||||
|
vendor_name: selectedVendor.legal_name || selectedVendor.doing_business_as
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return role;
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
vendor_id: vendorId,
|
||||||
|
vendor_name: selectedVendor.legal_name || selectedVendor.doing_business_as || "",
|
||||||
|
shifts: updatedShifts
|
||||||
|
};
|
||||||
|
});
|
||||||
|
updateGrandTotal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const calculateHours = (startTime, endTime, breakMinutes = 0) => {
|
const calculateHours = (startTime, endTime, breakMinutes = 0) => {
|
||||||
if (!startTime || !endTime) return 0;
|
if (!startTime || !endTime) return 0;
|
||||||
const [startHour, startMin] = startTime.split(':').map(Number);
|
const [startHour, startMin] = startTime.split(':').map(Number);
|
||||||
@@ -124,9 +181,21 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
return Math.max(0, totalMinutes / 60);
|
return Math.max(0, totalMinutes / 60);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRateForRole = (roleName) => {
|
const getRateForRole = (roleName, vendorId = null) => { // Modified getRateForRole
|
||||||
const rate = allRates.find(r => r.role_name === roleName && r.is_active);
|
const targetVendorId = vendorId || formData.vendor_id;
|
||||||
return rate ? parseFloat(rate.client_rate || 0) : 0;
|
|
||||||
|
if (targetVendorId) {
|
||||||
|
const rate = allRates.find(r =>
|
||||||
|
r.role_name === roleName &&
|
||||||
|
r.vendor_id === targetVendorId &&
|
||||||
|
r.is_active
|
||||||
|
);
|
||||||
|
if (rate) return parseFloat(rate.client_rate || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to any active rate if no specific vendor or vendor-specific rate is found
|
||||||
|
const fallbackRate = allRates.find(r => r.role_name === roleName && r.is_active);
|
||||||
|
return fallbackRate ? parseFloat(fallbackRate.client_rate || 0) : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRoleChange = (shiftIndex, roleIndex, field, value) => {
|
const handleRoleChange = (shiftIndex, roleIndex, field, value) => {
|
||||||
@@ -138,6 +207,8 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
if (field === 'role') {
|
if (field === 'role') {
|
||||||
const rate = getRateForRole(value);
|
const rate = getRateForRole(value);
|
||||||
role.rate_per_hour = rate;
|
role.rate_per_hour = rate;
|
||||||
|
role.vendor_id = prev.vendor_id; // Added vendor_id to role
|
||||||
|
role.vendor_name = prev.vendor_name; // Added vendor_name to role
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field === 'start_time' || field === 'end_time' || field === 'break_minutes') {
|
if (field === 'start_time' || field === 'end_time' || field === 'break_minutes') {
|
||||||
@@ -176,7 +247,9 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
uniform: "Type 1",
|
uniform: "Type 1",
|
||||||
break_minutes: 30, // Default to 30 min non-payable
|
break_minutes: 30, // Default to 30 min non-payable
|
||||||
rate_per_hour: 0,
|
rate_per_hour: 0,
|
||||||
total_value: 0
|
total_value: 0,
|
||||||
|
vendor_id: prev.vendor_id, // Added vendor_id to new role
|
||||||
|
vendor_name: prev.vendor_name // Added vendor_name to new role
|
||||||
});
|
});
|
||||||
return { ...prev, shifts: newShifts };
|
return { ...prev, shifts: newShifts };
|
||||||
});
|
});
|
||||||
@@ -588,6 +661,36 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
<CardContent className="p-4 space-y-3">
|
<CardContent className="p-4 space-y-3">
|
||||||
<Label className="text-xs font-bold">Event Details</Label>
|
<Label className="text-xs font-bold">Event Details</Label>
|
||||||
|
|
||||||
|
{/* Vendor Selection for Clients */}
|
||||||
|
{isClient && (
|
||||||
|
<div className="p-3 bg-blue-50 rounded-lg border-2 border-blue-200">
|
||||||
|
<Label className="text-xs font-semibold mb-2 block flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-blue-600" />
|
||||||
|
Select Vendor *
|
||||||
|
</Label>
|
||||||
|
<Select value={formData.vendor_id || ""} onValueChange={handleVendorChange}>
|
||||||
|
<SelectTrigger className="h-9 text-sm bg-white">
|
||||||
|
<SelectValue placeholder="Choose vendor for this order" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{vendors.filter(v => v.approval_status === 'approved').map((vendor) => (
|
||||||
|
<SelectItem key={vendor.id} value={vendor.id} className="text-sm">
|
||||||
|
{vendor.legal_name || vendor.doing_business_as}
|
||||||
|
{currentUserData?.preferred_vendor_id === vendor.id && (
|
||||||
|
<Badge className="ml-2 bg-blue-500 text-white text-xs">Preferred</Badge>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{formData.vendor_id && (
|
||||||
|
<p className="text-xs text-blue-600 mt-2">
|
||||||
|
✓ Rates will be automatically applied from {formData.vendor_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 1. Hub (first) */}
|
{/* 1. Hub (first) */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Hub *</Label>
|
<Label className="text-xs">Hub *</Label>
|
||||||
|
|||||||
@@ -1,84 +1,160 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Clock, MapPin, Users, DollarSign, UserPlus } from "lucide-react";
|
||||||
import { MapPin, Plus } from "lucide-react";
|
import SmartAssignModal from "./SmartAssignModal";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import AssignedStaffManager from "./AssignedStaffManager";
|
||||||
|
|
||||||
export default function ShiftCard({ shift, onNotifyStaff }) {
|
const convertTo12Hour = (time24) => {
|
||||||
|
if (!time24 || time24 === "—") return time24;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = time24.split(':');
|
||||||
|
if (!parts || parts.length < 2) return time24;
|
||||||
|
|
||||||
|
const hours = parseInt(parts[0], 10);
|
||||||
|
const minutes = parseInt(parts[1], 10);
|
||||||
|
|
||||||
|
if (isNaN(hours) || isNaN(minutes)) return time24;
|
||||||
|
|
||||||
|
const period = hours >= 12 ? 'PM' : 'AM';
|
||||||
|
const hours12 = hours % 12 || 12;
|
||||||
|
const minutesStr = minutes.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
return `${hours12}:${minutesStr} ${period}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error converting time:', error);
|
||||||
|
return time24;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ShiftCard({ shift, event }) {
|
||||||
|
const [assignModal, setAssignModal] = useState({ open: false, role: null });
|
||||||
|
|
||||||
|
const roles = shift?.roles || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-slate-200">
|
<>
|
||||||
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100 pb-4">
|
<Card className="bg-white border-2 border-slate-200 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<CardHeader className="border-b border-slate-100 bg-slate-50">
|
||||||
<CardTitle className="text-base font-semibold">{shift.shift_name || "Shift 1"}</CardTitle>
|
<div className="flex items-center justify-between">
|
||||||
<Button onClick={onNotifyStaff} className="bg-blue-600 hover:bg-blue-700 text-white text-sm">
|
|
||||||
Notify Staff
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-6 mt-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 mb-2">Managers:</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{shift.assigned_staff?.slice(0, 3).map((staff, idx) => (
|
|
||||||
<div key={idx} className="flex items-center gap-2">
|
|
||||||
<Avatar className="w-8 h-8 bg-slate-300">
|
|
||||||
<AvatarFallback className="text-xs">{staff.staff_name?.charAt(0)}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="text-xs">
|
|
||||||
<p className="font-medium">{staff.staff_name}</p>
|
|
||||||
<p className="text-slate-500">{staff.position || "john@email.com"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-2 text-xs">
|
|
||||||
<MapPin className="w-4 h-4 text-slate-400 mt-0.5" />
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Location:</p>
|
<CardTitle className="text-lg font-bold text-slate-900">
|
||||||
<p className="text-slate-600">{shift.location || "848 East Glen Road New York CA, USA"}</p>
|
{shift.shift_name || "Shift"}
|
||||||
|
</CardTitle>
|
||||||
|
{shift.location && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600 mt-1">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
{shift.location}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Badge className="bg-[#0A39DF] text-white font-semibold px-3 py-1.5">
|
||||||
|
{roles.length} Role{roles.length !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-6">
|
||||||
<Table>
|
<div className="space-y-4">
|
||||||
<TableHeader>
|
{roles.map((role, idx) => {
|
||||||
<TableRow className="bg-slate-50 hover:bg-slate-50">
|
const requiredCount = role.count || 1;
|
||||||
<TableHead className="text-xs">Unpaid break</TableHead>
|
const assignedCount = event?.assigned_staff?.filter(s => s.role === role.role)?.length || 0;
|
||||||
<TableHead className="text-xs">Count</TableHead>
|
const remainingCount = Math.max(requiredCount - assignedCount, 0);
|
||||||
<TableHead className="text-xs">Assigned</TableHead>
|
|
||||||
<TableHead className="text-xs">Uniform type</TableHead>
|
// Consistent status color logic
|
||||||
<TableHead className="text-xs">Price</TableHead>
|
const statusColor = remainingCount === 0
|
||||||
<TableHead className="text-xs">Amount</TableHead>
|
? "bg-green-100 text-green-700 border-green-300"
|
||||||
<TableHead className="text-xs">Actions</TableHead>
|
: assignedCount > 0
|
||||||
</TableRow>
|
? "bg-blue-100 text-blue-700 border-blue-300"
|
||||||
</TableHeader>
|
: "bg-slate-100 text-slate-700 border-slate-300";
|
||||||
<TableBody>
|
|
||||||
{(shift.assigned_staff || []).length > 0 ? (
|
return (
|
||||||
shift.assigned_staff.map((staff, idx) => (
|
<div
|
||||||
<TableRow key={idx}>
|
key={idx}
|
||||||
<TableCell className="text-xs">{shift.unpaid_break || 0}</TableCell>
|
className="border-2 border-slate-200 rounded-xl p-4 hover:shadow-sm transition-shadow bg-white"
|
||||||
<TableCell className="text-xs">1</TableCell>
|
>
|
||||||
<TableCell className="text-xs">0</TableCell>
|
<div className="flex items-center justify-between mb-4">
|
||||||
<TableCell className="text-xs">{shift.uniform_type || "uniform type"}</TableCell>
|
<div className="flex-1">
|
||||||
<TableCell className="text-xs">${shift.price || 23}</TableCell>
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<TableCell className="text-xs">{shift.amount || 120}</TableCell>
|
<h4 className="font-bold text-slate-900 text-lg">{role.role}</h4>
|
||||||
<TableCell>
|
<Badge className={`${statusColor} border-2 font-bold px-3 py-1`}>
|
||||||
<Button variant="ghost" size="sm" className="text-xs">⋮</Button>
|
{assignedCount} / {requiredCount} Assigned
|
||||||
</TableCell>
|
</Badge>
|
||||||
</TableRow>
|
</div>
|
||||||
))
|
|
||||||
) : (
|
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||||
<TableRow>
|
{role.start_time && role.end_time && (
|
||||||
<TableCell colSpan={7} className="text-center py-4 text-slate-500 text-xs">
|
<span className="flex items-center gap-1.5">
|
||||||
No staff assigned yet
|
<Clock className="w-4 h-4" />
|
||||||
</TableCell>
|
{convertTo12Hour(role.start_time)} - {convertTo12Hour(role.end_time)}
|
||||||
</TableRow>
|
</span>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
{role.department && (
|
||||||
</Table>
|
<Badge variant="outline" className="text-xs border-slate-300">
|
||||||
</CardContent>
|
{role.department}
|
||||||
</Card>
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{remainingCount > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setAssignModal({ open: true, role })}
|
||||||
|
className="bg-[#0A39DF] hover:bg-blue-700 gap-2 font-semibold"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
Assign Staff ({remainingCount} needed)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show assigned staff */}
|
||||||
|
{assignedCount > 0 && (
|
||||||
|
<div className="border-t border-slate-200 pt-4 mt-4">
|
||||||
|
<p className="text-xs font-bold text-slate-700 mb-3 uppercase tracking-wide">
|
||||||
|
Assigned Staff
|
||||||
|
</p>
|
||||||
|
<AssignedStaffManager event={event} shift={shift} role={role} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Additional role details */}
|
||||||
|
{(role.uniform || role.cost_per_hour) && (
|
||||||
|
<div className="grid grid-cols-2 gap-4 mt-4 pt-4 border-t border-slate-200">
|
||||||
|
{role.uniform && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Uniform</p>
|
||||||
|
<p className="text-sm font-medium text-slate-900">{role.uniform}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{role.cost_per_hour && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DollarSign className="w-4 h-4 text-[#0A39DF]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Rate</p>
|
||||||
|
<p className="text-sm font-bold text-slate-900">${role.cost_per_hour}/hr</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Smart Assignment Modal */}
|
||||||
|
<SmartAssignModal
|
||||||
|
open={assignModal.open}
|
||||||
|
onClose={() => setAssignModal({ open: false, role: null })}
|
||||||
|
event={event}
|
||||||
|
shift={shift}
|
||||||
|
role={assignModal.role}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
878
frontend-web/src/components/events/SmartAssignModal.jsx
Normal file
878
frontend-web/src/components/events/SmartAssignModal.jsx
Normal file
@@ -0,0 +1,878 @@
|
|||||||
|
|
||||||
|
import React, { useState, useMemo, useEffect } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Users,
|
||||||
|
AlertTriangle,
|
||||||
|
Star,
|
||||||
|
MapPin,
|
||||||
|
Sparkles,
|
||||||
|
Check,
|
||||||
|
Calendar,
|
||||||
|
Sliders,
|
||||||
|
TrendingUp,
|
||||||
|
Shield,
|
||||||
|
DollarSign,
|
||||||
|
Zap,
|
||||||
|
Bell,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
|
||||||
|
// Helper to check time overlap with buffer
|
||||||
|
function hasTimeOverlap(start1, end1, start2, end2, bufferMinutes = 30) {
|
||||||
|
const s1 = new Date(start1).getTime();
|
||||||
|
const e1 = new Date(end1).getTime() + bufferMinutes * 60 * 1000;
|
||||||
|
const s2 = new Date(start2).getTime();
|
||||||
|
const e2 = new Date(end2).getTime() + bufferMinutes * 60 * 1000;
|
||||||
|
|
||||||
|
return s1 < e2 && s2 < e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SmartAssignModal({ open, onClose, event, shift, role }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selected, setSelected] = useState(new Set());
|
||||||
|
const [sortMode, setSortMode] = useState("smart");
|
||||||
|
const [selectedRole, setSelectedRole] = useState(null); // New state to manage current selected role for assignment
|
||||||
|
|
||||||
|
// Smart assignment priorities
|
||||||
|
const [priorities, setPriorities] = useState({
|
||||||
|
skill: 100, // Skill is implied by position match, not a slider
|
||||||
|
reliability: 80,
|
||||||
|
fatigue: 60,
|
||||||
|
compliance: 70,
|
||||||
|
proximity: 50,
|
||||||
|
cost: 40,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelected(new Set());
|
||||||
|
setSearchQuery("");
|
||||||
|
|
||||||
|
// Auto-select first role if available or the one passed in props
|
||||||
|
if (event && !role) {
|
||||||
|
// If no specific role is passed, find roles that need assignment
|
||||||
|
const initialRoles = [];
|
||||||
|
(event.shifts || []).forEach(s => {
|
||||||
|
(s.roles || []).forEach(r => {
|
||||||
|
const currentAssignedCount = event.assigned_staff?.filter(staff =>
|
||||||
|
staff.role === r.role && staff.shift_name === s.shift_name
|
||||||
|
)?.length || 0;
|
||||||
|
if ((r.count || 0) > currentAssignedCount) {
|
||||||
|
initialRoles.push({ shift: s, role: r });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (initialRoles.length > 0) {
|
||||||
|
setSelectedRole(initialRoles[0]);
|
||||||
|
} else {
|
||||||
|
setSelectedRole(null); // No roles need assignment
|
||||||
|
}
|
||||||
|
} else if (shift && role) {
|
||||||
|
setSelectedRole({ shift, role });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, event, shift, role]);
|
||||||
|
|
||||||
|
const { data: allStaff = [] } = useQuery({
|
||||||
|
queryKey: ['staff-for-assignment'],
|
||||||
|
queryFn: () => base44.entities.Staff.list(),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: allEvents = [] } = useQuery({
|
||||||
|
queryKey: ['events-for-conflict-check'],
|
||||||
|
queryFn: () => base44.entities.Event.list(),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: vendorRates = [] } = useQuery({
|
||||||
|
queryKey: ['vendor-rates-assignment'],
|
||||||
|
queryFn: () => base44.entities.VendorRate.list(),
|
||||||
|
enabled: open,
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all roles that need assignment for display in the header
|
||||||
|
const allRoles = useMemo(() => {
|
||||||
|
if (!event) return [];
|
||||||
|
const roles = [];
|
||||||
|
(event.shifts || []).forEach(s => {
|
||||||
|
(s.roles || []).forEach(r => {
|
||||||
|
const currentAssignedCount = event.assigned_staff?.filter(staff =>
|
||||||
|
staff.role === r.role && staff.shift_name === s.shift_name
|
||||||
|
)?.length || 0;
|
||||||
|
const remaining = Math.max((r.count || 0) - currentAssignedCount, 0);
|
||||||
|
if (remaining > 0) {
|
||||||
|
roles.push({
|
||||||
|
shift: s,
|
||||||
|
role: r,
|
||||||
|
currentAssigned: currentAssignedCount,
|
||||||
|
remaining,
|
||||||
|
label: `${r.role} (${remaining} needed)`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return roles;
|
||||||
|
}, [event]);
|
||||||
|
|
||||||
|
// Use selectedRole for current assignment context
|
||||||
|
const currentRole = selectedRole?.role;
|
||||||
|
const currentShift = selectedRole?.shift;
|
||||||
|
|
||||||
|
const requiredCount = currentRole?.count || 1;
|
||||||
|
const currentAssigned = event?.assigned_staff?.filter(s =>
|
||||||
|
s.role === currentRole?.role && s.shift_name === currentShift?.shift_name
|
||||||
|
)?.length || 0;
|
||||||
|
const remainingCount = Math.max(requiredCount - currentAssigned, 0);
|
||||||
|
|
||||||
|
const eligibleStaff = useMemo(() => {
|
||||||
|
if (!currentRole || !event) return [];
|
||||||
|
|
||||||
|
return allStaff
|
||||||
|
.filter(staff => {
|
||||||
|
// Check if position matches
|
||||||
|
const positionMatch = staff.position === currentRole.role ||
|
||||||
|
staff.position_2 === currentRole.role ||
|
||||||
|
staff.position?.toLowerCase() === currentRole.role?.toLowerCase() ||
|
||||||
|
staff.position_2?.toLowerCase() === currentRole.role?.toLowerCase();
|
||||||
|
|
||||||
|
if (!positionMatch) return false;
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const nameMatch = staff.employee_name?.toLowerCase().includes(query);
|
||||||
|
const locationMatch = staff.hub_location?.toLowerCase().includes(query);
|
||||||
|
if (!nameMatch && !locationMatch) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(staff => {
|
||||||
|
// Check for time conflicts
|
||||||
|
const conflicts = allEvents.filter(e => {
|
||||||
|
if (e.id === event.id) return false; // Don't conflict with current event
|
||||||
|
if (e.status === "Canceled" || e.status === "Completed") return false; // Ignore past/canceled events
|
||||||
|
|
||||||
|
const isAssignedToEvent = e.assigned_staff?.some(s => s.staff_id === staff.id);
|
||||||
|
if (!isAssignedToEvent) return false; // Staff not assigned to this event
|
||||||
|
|
||||||
|
// Check for time overlap within the conflicting event's shifts
|
||||||
|
const eventShifts = e.shifts || [];
|
||||||
|
return eventShifts.some(eventShift => {
|
||||||
|
const eventRoles = eventShift.roles || [];
|
||||||
|
return eventRoles.some(eventRole => {
|
||||||
|
// Ensure staff is assigned to this specific role within the conflicting shift
|
||||||
|
const isStaffAssignedToThisRole = e.assigned_staff?.some(
|
||||||
|
s => s.staff_id === staff.id && s.role === eventRole.role && s.shift_name === eventShift.shift_name
|
||||||
|
);
|
||||||
|
if (!isStaffAssignedToThisRole) return false;
|
||||||
|
|
||||||
|
const shiftStart = `${e.date}T${eventRole.start_time || '00:00'}`;
|
||||||
|
const shiftEnd = `${e.date}T${eventRole.end_time || '23:59'}`;
|
||||||
|
const currentStart = `${event.date}T${currentRole.start_time || '00:00'}`;
|
||||||
|
const currentEnd = `${event.date}T${currentRole.end_time || '23:59'}`;
|
||||||
|
|
||||||
|
return hasTimeOverlap(shiftStart, shiftEnd, currentStart, currentEnd);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasConflict = conflicts.length > 0;
|
||||||
|
const totalShifts = staff.total_shifts || 0;
|
||||||
|
const reliability = staff.reliability_score || (totalShifts > 0 ? 85 : 0);
|
||||||
|
|
||||||
|
// Calculate smart scores
|
||||||
|
// Skill score is implicitly 100 if they pass the filter (position match)
|
||||||
|
const fatigueScore = 100 - Math.min((totalShifts / 30) * 100, 100); // More shifts = more fatigue = lower score
|
||||||
|
const complianceScore = staff.background_check_status === 'cleared' ? 100 : 50; // Simple compliance check
|
||||||
|
const proximityScore = staff.hub_location === event.hub ? 100 : 50; // Location match
|
||||||
|
const costRate = vendorRates.find(r => r.vendor_id === staff.vendor_id && r.role_name === currentRole.role);
|
||||||
|
const costScore = costRate ? Math.max(0, 100 - (costRate.client_rate / 50) * 100) : 50; // Lower rate = higher score
|
||||||
|
|
||||||
|
const smartScore = (
|
||||||
|
(priorities.skill / 100) * 100 + // Skill is 100 if eligible
|
||||||
|
(priorities.reliability / 100) * reliability +
|
||||||
|
(priorities.fatigue / 100) * fatigueScore +
|
||||||
|
(priorities.compliance / 100) * complianceScore +
|
||||||
|
(priorities.proximity / 100) * proximityScore +
|
||||||
|
(priorities.cost / 100) * costScore
|
||||||
|
) / 6; // Divided by number of priorities (6)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...staff,
|
||||||
|
hasConflict,
|
||||||
|
conflictDetails: conflicts,
|
||||||
|
reliability,
|
||||||
|
shiftCount: totalShifts,
|
||||||
|
smartScore,
|
||||||
|
scores: {
|
||||||
|
fatigue: fatigueScore,
|
||||||
|
compliance: complianceScore,
|
||||||
|
proximity: proximityScore,
|
||||||
|
cost: costScore,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (sortMode === "smart") {
|
||||||
|
// Prioritize non-conflicting staff first, then by smart score
|
||||||
|
if (a.hasConflict !== b.hasConflict) return a.hasConflict ? 1 : -1;
|
||||||
|
return b.smartScore - a.smartScore;
|
||||||
|
} else {
|
||||||
|
// Manual mode: Prioritize non-conflicting, then reliability, then shift count
|
||||||
|
if (a.hasConflict !== b.hasConflict) return a.hasConflict ? 1 : -1;
|
||||||
|
if (b.reliability !== a.reliability) return b.reliability - a.reliability;
|
||||||
|
return (b.shiftCount || 0) - (a.shiftCount || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [allStaff, allEvents, currentRole, event, currentShift, searchQuery, sortMode, priorities, vendorRates]);
|
||||||
|
|
||||||
|
const availableStaff = eligibleStaff.filter(s => !s.hasConflict);
|
||||||
|
const unavailableStaff = eligibleStaff.filter(s => s.hasConflict);
|
||||||
|
|
||||||
|
const handleSelectBest = () => {
|
||||||
|
const best = availableStaff.slice(0, remainingCount);
|
||||||
|
const newSelected = new Set(best.map(s => s.id));
|
||||||
|
setSelected(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelect = (staffId) => {
|
||||||
|
const newSelected = new Set(selected);
|
||||||
|
if (newSelected.has(staffId)) {
|
||||||
|
newSelected.delete(staffId);
|
||||||
|
} else {
|
||||||
|
if (newSelected.size >= remainingCount) {
|
||||||
|
toast({
|
||||||
|
title: "Limit Reached",
|
||||||
|
description: `You can only assign ${remainingCount} more ${currentRole.role}${remainingCount > 1 ? 's' : ''} to this role.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newSelected.add(staffId);
|
||||||
|
}
|
||||||
|
setSelected(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const selectedStaff = eligibleStaff.filter(s => selected.has(s.id));
|
||||||
|
|
||||||
|
// Send notifications to unavailable staff who are being assigned
|
||||||
|
const unavailableSelected = selectedStaff.filter(s => s.hasConflict);
|
||||||
|
for (const staff of unavailableSelected) {
|
||||||
|
try {
|
||||||
|
// This is a placeholder for sending an actual email/notification
|
||||||
|
// In a real application, you'd use a robust notification service.
|
||||||
|
await base44.integrations.Core.SendEmail({ // Assuming base44.integrations.Core exists and has SendEmail
|
||||||
|
to: staff.email || `${staff.employee_name.replace(/\s/g, '').toLowerCase()}@example.com`,
|
||||||
|
subject: `New Shift Assignment - ${event.event_name} (Possible Conflict)`,
|
||||||
|
body: `Dear ${staff.employee_name},\n\nYou have been assigned to work as a ${currentRole.role} for the event "${event.event_name}" on ${format(new Date(event.date), 'MMM d, yyyy')} from ${currentRole.start_time} to ${currentRole.end_time} at ${event.hub || event.event_location}.\n\nOur records indicate this assignment might overlap with another scheduled shift. Please review your schedule and confirm your availability for this new assignment as soon as possible.\n\nThank you!`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send email to conflicted staff:", staff.employee_name, error);
|
||||||
|
// Decide whether to block assignment or just log the error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAssignedStaff = [
|
||||||
|
...(event.assigned_staff || []),
|
||||||
|
...selectedStaff.map(s => ({
|
||||||
|
staff_id: s.id,
|
||||||
|
staff_name: s.employee_name,
|
||||||
|
email: s.email,
|
||||||
|
role: currentRole.role,
|
||||||
|
department: currentRole.department,
|
||||||
|
shift_name: currentShift.shift_name, // Include shift_name
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
const updatedShifts = (event.shifts || []).map(s => {
|
||||||
|
if (s.shift_name === currentShift.shift_name) {
|
||||||
|
const updatedRoles = (s.roles || []).map(r => {
|
||||||
|
if (r.role === currentRole.role) {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
assigned: (r.assigned || 0) + selected.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
return { ...s, roles: updatedRoles };
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
|
||||||
|
await base44.entities.Event.update(event.id, {
|
||||||
|
assigned_staff: updatedAssignedStaff,
|
||||||
|
shifts: updatedShifts,
|
||||||
|
requested: (event.requested || 0) + selected.size, // This `requested` field might need more careful handling if it's meant to be total
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['all-events-vendor'] }); // New query key
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['vendor-events'] }); // New query key
|
||||||
|
toast({
|
||||||
|
title: "✅ Staff Assigned",
|
||||||
|
description: `Successfully assigned ${selected.size} ${currentRole.role}${selected.size > 1 ? 's' : ''}`,
|
||||||
|
});
|
||||||
|
setSelected(new Set()); // Clear selection after assignment
|
||||||
|
|
||||||
|
// Auto-select the next role that needs assignment
|
||||||
|
const currentRoleIdentifier = { role: currentRole.role, shift_name: currentShift.shift_name };
|
||||||
|
const currentIndex = allRoles.findIndex(ar => ar.role.role === currentRoleIdentifier.role && ar.shift.shift_name === currentRoleIdentifier.shift_name);
|
||||||
|
|
||||||
|
if (currentIndex !== -1 && currentIndex + 1 < allRoles.length) {
|
||||||
|
setSelectedRole(allRoles[currentIndex + 1]);
|
||||||
|
} else {
|
||||||
|
onClose(); // Close if no more roles to assign
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "❌ Assignment Failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAssign = () => {
|
||||||
|
if (selected.size === 0) {
|
||||||
|
toast({
|
||||||
|
title: "No Selection",
|
||||||
|
description: "Please select at least one staff member",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The logic to check for conflicts and stop was removed because
|
||||||
|
// the new assignMutation now sends notifications to conflicted staff.
|
||||||
|
// If a hard stop for conflicts is desired, this check should be re-enabled
|
||||||
|
// and the notification logic in assignMutation modified.
|
||||||
|
|
||||||
|
assignMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!event) return null;
|
||||||
|
// If there's no currentRole, it means either props were not passed or all roles are already assigned
|
||||||
|
if (!currentRole || !currentShift) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>No Roles to Assign</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-slate-600">All positions for this order are fully staffed, or no roles were specified.</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={onClose}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor = remainingCount === 0
|
||||||
|
? "bg-green-100 text-green-700 border-green-300"
|
||||||
|
: currentAssigned > 0
|
||||||
|
? "bg-blue-100 text-blue-700 border-blue-300"
|
||||||
|
: "bg-slate-100 text-slate-700 border-slate-300";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader className="border-b pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-6 h-6 text-[#0A39DF]" />
|
||||||
|
Smart Assign Staff
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-sm text-slate-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
{event.event_name}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{event.date ? format(new Date(event.date), 'MMM d, yyyy') : 'Date TBD'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<Badge className={`${statusColor} border-2 text-lg px-4 py-2 font-bold`}>
|
||||||
|
{selected.size} / {remainingCount} Selected
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role Selector */}
|
||||||
|
{allRoles.length > 1 && (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{allRoles.map((roleItem, idx) => (
|
||||||
|
<Button
|
||||||
|
key={`${roleItem.shift.shift_name}-${roleItem.role.role}-${idx}`}
|
||||||
|
variant={roleItem.role.role === currentRole.role && roleItem.shift.shift_name === currentShift.shift_name ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRole(roleItem);
|
||||||
|
setSelected(new Set()); // Clear selection when changing roles
|
||||||
|
}}
|
||||||
|
className={roleItem.role.role === currentRole.role && roleItem.shift.shift_name === currentShift.shift_name ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white" : "border-slate-300"}
|
||||||
|
>
|
||||||
|
{roleItem.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs value={sortMode} onValueChange={setSortMode} className="flex-1 overflow-hidden flex flex-col">
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="smart" className="flex-1">
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
Smart Assignment
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="manual" className="flex-1">
|
||||||
|
<Users className="w-4 h-4 mr-2" />
|
||||||
|
Manual Selection
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="smart" className="flex-1 overflow-hidden flex flex-col mt-4 space-y-4">
|
||||||
|
{/* Priority Controls */}
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Sliders className="w-4 h-4 text-[#0A39DF]" />
|
||||||
|
<h4 className="font-semibold text-slate-900">Assignment Priorities</h4>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-3 h-3" />
|
||||||
|
Reliability
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-600">{priorities.reliability}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[priorities.reliability]}
|
||||||
|
onValueChange={(v) => setPriorities({...priorities, reliability: v[0]})}
|
||||||
|
max={100}
|
||||||
|
step={10}
|
||||||
|
className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
Fatigue
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-600">{priorities.fatigue}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[priorities.fatigue]}
|
||||||
|
onValueChange={(v) => setPriorities({...priorities, fatigue: v[0]})}
|
||||||
|
max={100}
|
||||||
|
step={10}
|
||||||
|
className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<Shield className="w-3 h-3" />
|
||||||
|
Compliance
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-600">{priorities.compliance}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[priorities.compliance]}
|
||||||
|
onValueChange={(v) => setPriorities({...priorities, compliance: v[0]})}
|
||||||
|
max={100}
|
||||||
|
step={10}
|
||||||
|
className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
Proximity
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-600">{priorities.proximity}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[priorities.proximity]}
|
||||||
|
onValueChange={(v) => setPriorities({...priorities, proximity: v[0]})}
|
||||||
|
max={100}
|
||||||
|
step={10}
|
||||||
|
className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 flex-1 overflow-hidden flex flex-col">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search employees..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 border-2 border-slate-200 focus:border-[#0A39DF]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Users className="w-4 h-4 text-[#0A39DF]" />
|
||||||
|
<span className="font-semibold text-slate-900">{availableStaff.length} Available</span>
|
||||||
|
</div>
|
||||||
|
{unavailableStaff.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-orange-600" />
|
||||||
|
<span className="font-semibold text-orange-600">{unavailableStaff.length} Unavailable</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSelectBest}
|
||||||
|
disabled={remainingCount === 0 || availableStaff.length === 0}
|
||||||
|
className="gap-2 bg-[#0A39DF] hover:bg-blue-700 font-semibold"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Auto-Select Best {remainingCount}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto border-2 border-slate-200 rounded-lg">
|
||||||
|
{eligibleStaff.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-slate-500">
|
||||||
|
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="font-medium">No {currentRole.role}s found</p>
|
||||||
|
<p className="text-sm">Try adjusting your search or check staff positions</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{/* Available Staff First */}
|
||||||
|
{availableStaff.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="bg-green-50 px-4 py-2 sticky top-0 z-10 border-b border-green-100">
|
||||||
|
<p className="text-xs font-bold text-green-700 uppercase">Available ({availableStaff.length})</p>
|
||||||
|
</div>
|
||||||
|
{availableStaff.map((staff) => {
|
||||||
|
const isSelected = selected.has(staff.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={staff.id}
|
||||||
|
className={`p-4 flex items-center gap-4 transition-all cursor-pointer ${
|
||||||
|
isSelected ? 'bg-blue-50 border-l-4 border-[#0A39DF]' : 'hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleSelect(staff.id)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleSelect(staff.id)}
|
||||||
|
className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Avatar className="w-12 h-12">
|
||||||
|
<img
|
||||||
|
src={staff.profile_picture || `https://ui-avatars.com/api/?name=${encodeURIComponent(staff.employee_name || 'Staff')}&background=0A39DF&color=fff&size=128&bold=true`}
|
||||||
|
alt={staff.employee_name}
|
||||||
|
className="w-full h-full object-cover rounded-full"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<p className="font-semibold text-slate-900">{staff.employee_name}</p>
|
||||||
|
<Badge variant="outline" className="text-xs bg-gradient-to-r from-[#0A39DF] to-blue-600 text-white border-0">
|
||||||
|
{Math.round(staff.smartScore)}% Match
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-3 h-3" />
|
||||||
|
{staff.reliability}%
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
{Math.round(staff.scores.fatigue)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Shield className="w-3 h-3" />
|
||||||
|
{Math.round(staff.scores.compliance)}
|
||||||
|
</span>
|
||||||
|
{staff.hub_location && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{staff.hub_location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<Badge variant="secondary" className="text-xs bg-slate-100 text-slate-700 border border-slate-300">
|
||||||
|
{staff.shiftCount || 0} shifts
|
||||||
|
</Badge>
|
||||||
|
<Badge className="bg-green-100 text-green-700 border border-green-300 text-xs font-semibold">
|
||||||
|
Available
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unavailable Staff */}
|
||||||
|
{unavailableStaff.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="bg-orange-50 px-4 py-2 sticky top-0 z-10 border-b border-orange-100">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-xs font-bold text-orange-700 uppercase">Unavailable ({unavailableStaff.length})</p>
|
||||||
|
<Bell className="w-3 h-3 text-orange-700" />
|
||||||
|
<span className="text-xs text-orange-600">Will be notified if assigned</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{unavailableStaff.map((staff) => {
|
||||||
|
const isSelected = selected.has(staff.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={staff.id}
|
||||||
|
className={`p-4 flex items-center gap-4 transition-all cursor-pointer ${
|
||||||
|
isSelected ? 'bg-blue-50 border-l-4 border-[#0A39DF]' : 'hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleSelect(staff.id)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleSelect(staff.id)}
|
||||||
|
className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Avatar className="w-12 h-12">
|
||||||
|
<img
|
||||||
|
src={staff.profile_picture || `https://ui-avatars.com/api/?name=${encodeURIComponent(staff.employee_name || 'Staff')}&background=f97316&color=fff&size=128&bold=true`}
|
||||||
|
alt={staff.employee_name}
|
||||||
|
className="w-full h-full object-cover rounded-full"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<p className="font-semibold text-slate-900">{staff.employee_name}</p>
|
||||||
|
<Badge variant="outline" className="text-xs bg-gradient-to-r from-orange-500 to-orange-600 text-white border-0">
|
||||||
|
{Math.round(staff.smartScore)}% Match
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-orange-600" />
|
||||||
|
Time Conflict
|
||||||
|
</span>
|
||||||
|
{staff.hub_location && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{staff.hub_location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<Badge variant="secondary" className="text-xs bg-slate-100 text-slate-700 border border-slate-300">
|
||||||
|
{staff.shiftCount || 0} shifts
|
||||||
|
</Badge>
|
||||||
|
<Badge className="bg-orange-100 text-orange-700 border border-orange-300 text-xs font-semibold">
|
||||||
|
Will Notify
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="manual" className="flex-1 overflow-hidden flex flex-col mt-4 space-y-4">
|
||||||
|
<div className="space-y-3 flex-1 overflow-hidden flex flex-col">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search employees..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 border-2 border-slate-200 focus:border-[#0A39DF]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Users className="w-4 h-4 text-[#0A39DF]" />
|
||||||
|
<span className="font-semibold text-slate-900">{availableStaff.length} Available {currentRole.role}s</span>
|
||||||
|
</div>
|
||||||
|
{unavailableStaff.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-orange-600" />
|
||||||
|
<span className="font-semibold text-orange-600">{unavailableStaff.length} Conflicts</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSelectBest}
|
||||||
|
disabled={remainingCount === 0 || availableStaff.length === 0}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 border-2 border-[#0A39DF] text-[#0A39DF] hover:bg-blue-50 font-semibold"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
Select Top {remainingCount}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto border-2 border-slate-200 rounded-lg">
|
||||||
|
{eligibleStaff.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-slate-500">
|
||||||
|
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="font-medium">No {currentRole.role}s found</p>
|
||||||
|
<p className="text-sm">Try adjusting your search or filters</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{eligibleStaff.map((staff) => {
|
||||||
|
const isSelected = selected.has(staff.id);
|
||||||
|
// In manual mode, we still allow selection of conflicted staff,
|
||||||
|
// and the system will notify them.
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={staff.id}
|
||||||
|
className={`p-4 flex items-center gap-4 transition-all ${
|
||||||
|
isSelected ? 'bg-blue-50 border-l-4 border-[#0A39DF]' : 'hover:bg-slate-50'
|
||||||
|
} cursor-pointer`}
|
||||||
|
onClick={() => toggleSelect(staff.id)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleSelect(staff.id)}
|
||||||
|
className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Avatar className="w-12 h-12">
|
||||||
|
<img
|
||||||
|
src={staff.profile_picture || `https://ui-avatars.com/api/?name=${encodeURIComponent(staff.employee_name || 'Staff')}&background=0A39DF&color=fff&size=128&bold=true`}
|
||||||
|
alt={staff.employee_name}
|
||||||
|
className="w-full h-full object-cover rounded-full"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<p className="font-semibold text-slate-900">{staff.employee_name}</p>
|
||||||
|
{staff.rating && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Star className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />
|
||||||
|
<span className="text-sm font-medium text-slate-700">{staff.rating.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-600">
|
||||||
|
<Badge variant="outline" className="text-xs border-slate-300">
|
||||||
|
{currentRole.role}
|
||||||
|
</Badge>
|
||||||
|
{staff.hub_location && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{staff.hub_location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<Badge variant="secondary" className="text-xs bg-slate-100 text-slate-700 border border-slate-300">
|
||||||
|
{staff.shiftCount || 0} shifts
|
||||||
|
</Badge>
|
||||||
|
{staff.hasConflict ? (
|
||||||
|
<Badge className="bg-orange-100 text-orange-700 border border-orange-300 text-xs font-semibold">
|
||||||
|
Conflict (Will Notify)
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-green-100 text-green-700 border border-green-300 text-xs font-semibold">
|
||||||
|
Available
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<DialogFooter className="border-t pt-4">
|
||||||
|
<Button variant="outline" onClick={onClose} className="border-2 border-slate-300">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAssign}
|
||||||
|
disabled={selected.size === 0 || assignMutation.isPending}
|
||||||
|
className="bg-[#0A39DF] hover:bg-blue-700 font-semibold"
|
||||||
|
>
|
||||||
|
{assignMutation.isPending ? (
|
||||||
|
"Assigning..."
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="w-4 h-4 mr-2" />
|
||||||
|
Assign {selected.size} {selected.size === 1 ? 'Employee' : 'Employees'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
340
frontend-web/src/components/events/VendorRoutingPanel.jsx
Normal file
340
frontend-web/src/components/events/VendorRoutingPanel.jsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Award, Star, MapPin, Users, TrendingUp, AlertTriangle, Zap, CheckCircle2, Send } from "lucide-react";
|
||||||
|
|
||||||
|
export default function VendorRoutingPanel({
|
||||||
|
user,
|
||||||
|
selectedVendors = [],
|
||||||
|
onVendorChange,
|
||||||
|
isRapid = false
|
||||||
|
}) {
|
||||||
|
const [showVendorSelector, setShowVendorSelector] = useState(false);
|
||||||
|
const [selectionMode, setSelectionMode] = useState('single'); // 'single' | 'multi'
|
||||||
|
|
||||||
|
// Fetch preferred vendor
|
||||||
|
const { data: preferredVendor } = useQuery({
|
||||||
|
queryKey: ['preferred-vendor-routing', user?.preferred_vendor_id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!user?.preferred_vendor_id) return null;
|
||||||
|
const vendors = await base44.entities.Vendor.list();
|
||||||
|
return vendors.find(v => v.id === user.preferred_vendor_id);
|
||||||
|
},
|
||||||
|
enabled: !!user?.preferred_vendor_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all approved vendors
|
||||||
|
const { data: allVendors } = useQuery({
|
||||||
|
queryKey: ['all-vendors-routing'],
|
||||||
|
queryFn: () => base44.entities.Vendor.filter({
|
||||||
|
approval_status: 'approved',
|
||||||
|
is_active: true
|
||||||
|
}),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-select preferred vendor on mount if none selected
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (preferredVendor && selectedVendors.length === 0) {
|
||||||
|
onVendorChange([preferredVendor]);
|
||||||
|
}
|
||||||
|
}, [preferredVendor]);
|
||||||
|
|
||||||
|
const handleVendorSelect = (vendor) => {
|
||||||
|
if (selectionMode === 'single') {
|
||||||
|
onVendorChange([vendor]);
|
||||||
|
setShowVendorSelector(false);
|
||||||
|
} else {
|
||||||
|
// Multi-select mode
|
||||||
|
const isSelected = selectedVendors.some(v => v.id === vendor.id);
|
||||||
|
if (isSelected) {
|
||||||
|
onVendorChange(selectedVendors.filter(v => v.id !== vendor.id));
|
||||||
|
} else {
|
||||||
|
onVendorChange([...selectedVendors, vendor]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMultiVendorDone = () => {
|
||||||
|
if (selectedVendors.length === 0) {
|
||||||
|
alert("Please select at least one vendor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowVendorSelector(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const routingMode = selectedVendors.length > 1 ? 'multi' : 'single';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className={`border-2 ${
|
||||||
|
isRapid
|
||||||
|
? 'border-red-300 bg-gradient-to-br from-red-50 to-orange-50'
|
||||||
|
: 'border-blue-300 bg-gradient-to-br from-blue-50 to-indigo-50'
|
||||||
|
}`}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-8 h-8 ${
|
||||||
|
isRapid ? 'bg-red-600' : 'bg-blue-600'
|
||||||
|
} rounded-lg flex items-center justify-center`}>
|
||||||
|
{isRapid ? (
|
||||||
|
<Zap className="w-4 h-4 text-white" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-4 h-4 text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-slate-600 uppercase tracking-wider">
|
||||||
|
{isRapid ? 'RAPID ORDER ROUTING' : 'Order Routing'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{routingMode === 'multi'
|
||||||
|
? `Sending to ${selectedVendors.length} vendors`
|
||||||
|
: 'Default vendor routing'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{routingMode === 'multi' && (
|
||||||
|
<Badge className="bg-purple-600 text-white font-bold">
|
||||||
|
MULTI-VENDOR
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Vendor(s) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedVendors.length === 0 && !preferredVendor && (
|
||||||
|
<div className="p-3 bg-amber-50 border-2 border-amber-200 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0" />
|
||||||
|
<p className="text-amber-800">
|
||||||
|
<strong>No vendor selected.</strong> Please choose a vendor.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedVendors.map((vendor) => {
|
||||||
|
const isPreferred = vendor.id === preferredVendor?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={vendor.id}
|
||||||
|
className="p-3 bg-white border-2 border-slate-200 rounded-lg hover:border-blue-300 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<p className="font-bold text-sm text-slate-900">
|
||||||
|
{vendor.doing_business_as || vendor.legal_name}
|
||||||
|
</p>
|
||||||
|
{isPreferred && (
|
||||||
|
<Badge className="bg-blue-600 text-white text-xs px-2 py-0 border-0">
|
||||||
|
<Award className="w-3 h-3 mr-1" />
|
||||||
|
Preferred
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
{vendor.region && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{vendor.region}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
{vendor.workforce_count || 0} staff
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{routingMode === 'multi' && (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-slate-200">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectionMode('single');
|
||||||
|
setShowVendorSelector(true);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{selectedVendors.length === 0 ? 'Choose Vendor' : 'Change Vendor'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectionMode('multi');
|
||||||
|
setShowVendorSelector(true);
|
||||||
|
}}
|
||||||
|
className="text-xs bg-purple-50 border-purple-300 text-purple-700 hover:bg-purple-100"
|
||||||
|
>
|
||||||
|
<Zap className="w-3 h-3 mr-1" />
|
||||||
|
Multi-Vendor
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Banner */}
|
||||||
|
{routingMode === 'multi' && (
|
||||||
|
<div className="bg-purple-50 border-2 border-purple-200 rounded-lg p-2">
|
||||||
|
<p className="text-xs text-purple-800">
|
||||||
|
<strong>Multi-Vendor Mode:</strong> Order sent to all selected vendors.
|
||||||
|
First to confirm gets the job.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRapid && (
|
||||||
|
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-2">
|
||||||
|
<p className="text-xs text-red-800">
|
||||||
|
<strong>RAPID Priority:</strong> This order will be marked urgent with priority notification.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Vendor Selector Dialog */}
|
||||||
|
<Dialog open={showVendorSelector} onOpenChange={setShowVendorSelector}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
{selectionMode === 'multi' ? (
|
||||||
|
<>
|
||||||
|
<Zap className="w-6 h-6 text-purple-600" />
|
||||||
|
Select Multiple Vendors
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="w-6 h-6 text-blue-600" />
|
||||||
|
Select Vendor
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{selectionMode === 'multi'
|
||||||
|
? 'Select multiple vendors to send this order to all at once. First to confirm gets the job.'
|
||||||
|
: 'Choose which vendor should receive this order'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 mt-4">
|
||||||
|
{allVendors.map((vendor) => {
|
||||||
|
const isSelected = selectedVendors.some(v => v.id === vendor.id);
|
||||||
|
const isPreferred = vendor.id === preferredVendor?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={vendor.id}
|
||||||
|
className={`p-4 rounded-lg border-2 transition-all cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'border-blue-400 bg-blue-50'
|
||||||
|
: 'border-slate-200 hover:border-blue-300 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleVendorSelect(vendor)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h3 className="font-bold text-lg text-slate-900">
|
||||||
|
{vendor.doing_business_as || vendor.legal_name}
|
||||||
|
</h3>
|
||||||
|
{isPreferred && (
|
||||||
|
<Badge className="bg-blue-600 text-white">
|
||||||
|
<Award className="w-3 h-3 mr-1" />
|
||||||
|
Preferred
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isSelected && selectionMode === 'multi' && (
|
||||||
|
<Badge className="bg-green-600 text-white">
|
||||||
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
|
Selected
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||||
|
{vendor.region && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<MapPin className="w-3 h-3 mr-1" />
|
||||||
|
{vendor.region}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{vendor.service_specialty && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{vendor.service_specialty}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{vendor.workforce_count || 0} staff
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
||||||
|
4.9
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-4 h-4 text-green-600" />
|
||||||
|
98% fill rate
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{allVendors.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-slate-400">
|
||||||
|
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="font-medium">No vendors available</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectionMode === 'multi' && (
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t">
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{selectedVendors.length} vendor{selectedVendors.length !== 1 ? 's' : ''} selected
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleMultiVendorDone}
|
||||||
|
disabled={selectedVendors.length === 0}
|
||||||
|
className="bg-purple-600 hover:bg-purple-700"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||||
|
Confirm Selection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
frontend-web/src/components/notifications/NotificationEngine.jsx
Normal file
247
frontend-web/src/components/notifications/NotificationEngine.jsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automated Notification Engine
|
||||||
|
* Monitors events and triggers notifications based on configured preferences
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function NotificationEngine() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: events = [] } = useQuery({
|
||||||
|
queryKey: ['events-notifications'],
|
||||||
|
queryFn: () => base44.entities.Event.list(),
|
||||||
|
refetchInterval: 60000, // Check every minute
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: users = [] } = useQuery({
|
||||||
|
queryKey: ['users-notifications'],
|
||||||
|
queryFn: () => base44.entities.User.list(),
|
||||||
|
refetchInterval: 300000, // Check every 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const createNotification = async (userId, title, description, activityType, relatedId = null) => {
|
||||||
|
try {
|
||||||
|
await base44.entities.ActivityLog.create({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
activity_type: activityType,
|
||||||
|
user_id: userId,
|
||||||
|
is_read: false,
|
||||||
|
related_entity_id: relatedId,
|
||||||
|
icon_type: activityType.includes('event') ? 'calendar' : activityType.includes('invoice') ? 'invoice' : 'user',
|
||||||
|
icon_color: 'blue',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create notification:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendEmail = async (to, subject, body, userPreferences) => {
|
||||||
|
if (!userPreferences?.email_notifications) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await base44.integrations.Core.SendEmail({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send email:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shift assignment notifications
|
||||||
|
useEffect(() => {
|
||||||
|
const notifyStaffAssignments = async () => {
|
||||||
|
for (const event of events) {
|
||||||
|
if (!event.assigned_staff || event.assigned_staff.length === 0) continue;
|
||||||
|
|
||||||
|
for (const staff of event.assigned_staff) {
|
||||||
|
const user = users.find(u => u.email === staff.email);
|
||||||
|
if (!user) continue;
|
||||||
|
|
||||||
|
const prefs = user.notification_preferences || {};
|
||||||
|
if (!prefs.shift_assignments) continue;
|
||||||
|
|
||||||
|
// Check if notification already sent (within last 24h)
|
||||||
|
const recentNotifs = await base44.entities.ActivityLog.filter({
|
||||||
|
user_id: user.id,
|
||||||
|
activity_type: 'staff_assigned',
|
||||||
|
related_entity_id: event.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const alreadyNotified = recentNotifs.some(n => {
|
||||||
|
const notifDate = new Date(n.created_date);
|
||||||
|
const hoursSince = (Date.now() - notifDate.getTime()) / (1000 * 60 * 60);
|
||||||
|
return hoursSince < 24;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (alreadyNotified) continue;
|
||||||
|
|
||||||
|
await createNotification(
|
||||||
|
user.id,
|
||||||
|
'🎯 New Shift Assignment',
|
||||||
|
`You've been assigned to ${event.event_name} on ${new Date(event.date).toLocaleDateString()}`,
|
||||||
|
'staff_assigned',
|
||||||
|
event.id
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendEmail(
|
||||||
|
staff.email,
|
||||||
|
`New Shift Assignment - ${event.event_name}`,
|
||||||
|
`Hello ${staff.staff_name},\n\nYou've been assigned to work at ${event.event_name}.\n\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\n\nPlease confirm your availability in the KROW platform.\n\nThank you!`,
|
||||||
|
prefs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0 && users.length > 0) {
|
||||||
|
notifyStaffAssignments();
|
||||||
|
}
|
||||||
|
}, [events, users]);
|
||||||
|
|
||||||
|
// Shift reminder (24 hours before)
|
||||||
|
useEffect(() => {
|
||||||
|
const sendShiftReminders = async () => {
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
tomorrow.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const tomorrowEnd = new Date(tomorrow);
|
||||||
|
tomorrowEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const eventDate = new Date(event.date);
|
||||||
|
if (eventDate < tomorrow || eventDate > tomorrowEnd) continue;
|
||||||
|
if (!event.assigned_staff || event.assigned_staff.length === 0) continue;
|
||||||
|
|
||||||
|
for (const staff of event.assigned_staff) {
|
||||||
|
const user = users.find(u => u.email === staff.email);
|
||||||
|
if (!user) continue;
|
||||||
|
|
||||||
|
const prefs = user.notification_preferences || {};
|
||||||
|
if (!prefs.shift_reminders) continue;
|
||||||
|
|
||||||
|
await createNotification(
|
||||||
|
user.id,
|
||||||
|
'⏰ Shift Reminder',
|
||||||
|
`Reminder: Your shift at ${event.event_name} is tomorrow`,
|
||||||
|
'event_updated',
|
||||||
|
event.id
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendEmail(
|
||||||
|
staff.email,
|
||||||
|
`Shift Reminder - Tomorrow at ${event.event_name}`,
|
||||||
|
`Hello ${staff.staff_name},\n\nThis is a reminder that you have a shift tomorrow:\n\nEvent: ${event.event_name}\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\n\nSee you there!`,
|
||||||
|
prefs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0 && users.length > 0) {
|
||||||
|
sendShiftReminders();
|
||||||
|
}
|
||||||
|
}, [events, users]);
|
||||||
|
|
||||||
|
// Client upcoming event notifications (3 days before)
|
||||||
|
useEffect(() => {
|
||||||
|
const notifyClientsUpcomingEvents = async () => {
|
||||||
|
const threeDaysFromNow = new Date();
|
||||||
|
threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3);
|
||||||
|
threeDaysFromNow.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const threeDaysEnd = new Date(threeDaysFromNow);
|
||||||
|
threeDaysEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const eventDate = new Date(event.date);
|
||||||
|
if (eventDate < threeDaysFromNow || eventDate > threeDaysEnd) continue;
|
||||||
|
|
||||||
|
const clientUser = users.find(u =>
|
||||||
|
u.email === event.client_email ||
|
||||||
|
(u.role === 'client' && u.full_name === event.client_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!clientUser) continue;
|
||||||
|
|
||||||
|
const prefs = clientUser.notification_preferences || {};
|
||||||
|
if (!prefs.upcoming_events) continue;
|
||||||
|
|
||||||
|
await createNotification(
|
||||||
|
clientUser.id,
|
||||||
|
'📅 Upcoming Event',
|
||||||
|
`Your event "${event.event_name}" is in 3 days`,
|
||||||
|
'event_created',
|
||||||
|
event.id
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendEmail(
|
||||||
|
clientUser.email,
|
||||||
|
`Upcoming Event Reminder - ${event.event_name}`,
|
||||||
|
`Hello,\n\nThis is a reminder that your event is coming up in 3 days:\n\nEvent: ${event.event_name}\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\nStaff Assigned: ${event.assigned_staff?.length || 0}/${event.requested || 0}\n\nIf you need to make any changes, please log into your KROW account.`,
|
||||||
|
prefs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0 && users.length > 0) {
|
||||||
|
notifyClientsUpcomingEvents();
|
||||||
|
}
|
||||||
|
}, [events, users]);
|
||||||
|
|
||||||
|
// Vendor new lead notifications (new events without vendor assignment)
|
||||||
|
useEffect(() => {
|
||||||
|
const notifyVendorsNewLeads = async () => {
|
||||||
|
const newEvents = events.filter(e =>
|
||||||
|
e.status === 'Draft' || e.status === 'Pending'
|
||||||
|
);
|
||||||
|
|
||||||
|
const vendorUsers = users.filter(u => u.role === 'vendor');
|
||||||
|
|
||||||
|
for (const event of newEvents) {
|
||||||
|
for (const vendor of vendorUsers) {
|
||||||
|
const prefs = vendor.notification_preferences || {};
|
||||||
|
if (!prefs.new_leads) continue;
|
||||||
|
|
||||||
|
// Check if already notified
|
||||||
|
const recentNotifs = await base44.entities.ActivityLog.filter({
|
||||||
|
user_id: vendor.id,
|
||||||
|
activity_type: 'event_created',
|
||||||
|
related_entity_id: event.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recentNotifs.length > 0) continue;
|
||||||
|
|
||||||
|
await createNotification(
|
||||||
|
vendor.id,
|
||||||
|
'🎯 New Lead Available',
|
||||||
|
`New opportunity: ${event.event_name} needs ${event.requested || 0} staff`,
|
||||||
|
'event_created',
|
||||||
|
event.id
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendEmail(
|
||||||
|
vendor.email,
|
||||||
|
`New Staffing Opportunity - ${event.event_name}`,
|
||||||
|
`Hello,\n\nA new staffing opportunity is available:\n\nEvent: ${event.event_name}\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\nStaff Needed: ${event.requested || 0}\n\nLog in to KROW to submit your proposal.`,
|
||||||
|
prefs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0 && users.length > 0) {
|
||||||
|
notifyVendorsNewLeads();
|
||||||
|
}
|
||||||
|
}, [events, users]);
|
||||||
|
|
||||||
|
return null; // Background service
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationEngine;
|
||||||
141
frontend-web/src/components/onboarding/CompletionStep.jsx
Normal file
141
frontend-web/src/components/onboarding/CompletionStep.jsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { CheckCircle, User, FileText, BookOpen, Sparkles } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
export default function CompletionStep({ data, onComplete, onBack, isSubmitting }) {
|
||||||
|
const { profile, documents, training } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Sparkles className="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 mb-2">You're All Set! 🎉</h2>
|
||||||
|
<p className="text-slate-500">Review your information before completing onboarding</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Profile Summary */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<User className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-2">Profile Information</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Name</p>
|
||||||
|
<p className="font-medium">{profile.full_name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Email</p>
|
||||||
|
<p className="font-medium">{profile.email}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Position</p>
|
||||||
|
<p className="font-medium">{profile.position}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Location</p>
|
||||||
|
<p className="font-medium">{profile.city}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Documents Summary */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<FileText className="w-5 h-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-2">Documents Uploaded</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{documents.map((doc, idx) => (
|
||||||
|
<Badge key={idx} variant="outline" className="text-xs">
|
||||||
|
{doc.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{documents.length === 0 && (
|
||||||
|
<p className="text-sm text-slate-500">No documents uploaded</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Training Summary */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<BookOpen className="w-5 h-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-2">Training Completed</h3>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{training.completed.length} training modules completed
|
||||||
|
</p>
|
||||||
|
{training.acknowledged && (
|
||||||
|
<Badge className="mt-2 bg-green-500">Compliance Acknowledged</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next Steps */}
|
||||||
|
<Card className="bg-gradient-to-r from-blue-50 to-purple-50 border-blue-200">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-2">What Happens Next?</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-slate-600">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>Your profile will be activated and available for shift assignments</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>You'll receive an email confirmation with your login credentials</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>Our team will review your documents within 24-48 hours</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>You can start accepting shift invitations immediately</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
|
<Button variant="outline" onClick={onBack} disabled={isSubmitting}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onComplete}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="bg-gradient-to-r from-[#0A39DF] to-blue-600"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Creating Profile..." : "Complete Onboarding"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
frontend-web/src/components/onboarding/DocumentUploadStep.jsx
Normal file
159
frontend-web/src/components/onboarding/DocumentUploadStep.jsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Upload, FileText, CheckCircle, X } from "lucide-react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
const requiredDocuments = [
|
||||||
|
{ id: 'id', name: 'Government ID', required: true, description: 'Driver\'s license or passport' },
|
||||||
|
{ id: 'certification', name: 'Certifications', required: false, description: 'Food handler, TIPS, etc.' },
|
||||||
|
{ id: 'background_check', name: 'Background Check', required: false, description: 'If available' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DocumentUploadStep({ data, onNext, onBack }) {
|
||||||
|
const [documents, setDocuments] = useState(data || []);
|
||||||
|
const [uploading, setUploading] = useState({});
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleFileUpload = async (docType, file) => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(prev => ({ ...prev, [docType]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||||
|
|
||||||
|
const newDoc = {
|
||||||
|
type: docType,
|
||||||
|
name: file.name,
|
||||||
|
url: file_url,
|
||||||
|
uploaded_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setDocuments(prev => {
|
||||||
|
const filtered = prev.filter(d => d.type !== docType);
|
||||||
|
return [...filtered, newDoc];
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "✅ Document Uploaded",
|
||||||
|
description: `${file.name} uploaded successfully`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "❌ Upload Failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUploading(prev => ({ ...prev, [docType]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveDocument = (docType) => {
|
||||||
|
setDocuments(prev => prev.filter(d => d.type !== docType));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
const hasRequiredDocs = requiredDocuments
|
||||||
|
.filter(doc => doc.required)
|
||||||
|
.every(doc => documents.some(d => d.type === doc.id));
|
||||||
|
|
||||||
|
if (!hasRequiredDocs) {
|
||||||
|
toast({
|
||||||
|
title: "⚠️ Missing Required Documents",
|
||||||
|
description: "Please upload all required documents before continuing",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onNext({ type: 'documents', data: documents });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUploadedDoc = (docType) => documents.find(d => d.type === docType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 mb-2">Document Upload</h2>
|
||||||
|
<p className="text-sm text-slate-500">Upload required documents for compliance</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{requiredDocuments.map(doc => {
|
||||||
|
const uploadedDoc = getUploadedDoc(doc.id);
|
||||||
|
const isUploading = uploading[doc.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={doc.id} className={uploadedDoc ? "border-green-500 bg-green-50" : ""}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Label className="font-semibold">
|
||||||
|
{doc.name}
|
||||||
|
{doc.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
{uploadedDoc && (
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500">{doc.description}</p>
|
||||||
|
|
||||||
|
{uploadedDoc && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-slate-500" />
|
||||||
|
<span className="text-sm text-slate-700">{uploadedDoc.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveDocument(doc.id)}
|
||||||
|
className="ml-2 text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={`upload-${doc.id}`}
|
||||||
|
className={`cursor-pointer inline-flex items-center px-4 py-2 rounded-lg ${
|
||||||
|
uploadedDoc
|
||||||
|
? "bg-green-100 text-green-700 hover:bg-green-200"
|
||||||
|
: "bg-blue-100 text-blue-700 hover:bg-blue-200"
|
||||||
|
} transition-colors`}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
{isUploading ? "Uploading..." : uploadedDoc ? "Replace" : "Upload"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`upload-${doc.id}`}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png"
|
||||||
|
onChange={(e) => handleFileUpload(doc.id, e.target.files[0])}
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
|
<Button variant="outline" onClick={onBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleNext} className="bg-[#0A39DF]">
|
||||||
|
Continue to Training
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
frontend-web/src/components/onboarding/ProfileSetupStep.jsx
Normal file
193
frontend-web/src/components/onboarding/ProfileSetupStep.jsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { User, Briefcase, MapPin } from "lucide-react";
|
||||||
|
|
||||||
|
export default function ProfileSetupStep({ data, onNext, currentUser }) {
|
||||||
|
const [profile, setProfile] = useState({
|
||||||
|
full_name: data.full_name || currentUser?.full_name || "",
|
||||||
|
email: data.email || currentUser?.email || "",
|
||||||
|
phone: data.phone || "",
|
||||||
|
address: data.address || "",
|
||||||
|
city: data.city || "",
|
||||||
|
position: data.position || "",
|
||||||
|
department: data.department || "",
|
||||||
|
hub_location: data.hub_location || "",
|
||||||
|
employment_type: data.employment_type || "Full Time",
|
||||||
|
english_level: data.english_level || "Fluent",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onNext({ type: 'profile', data: profile });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (field, value) => {
|
||||||
|
setProfile(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 mb-2">Profile Setup</h2>
|
||||||
|
<p className="text-sm text-slate-500">Tell us about yourself</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Personal Information */}
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
<span>Personal Information</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="full_name">Full Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="full_name"
|
||||||
|
value={profile.full_name}
|
||||||
|
onChange={(e) => handleChange('full_name', e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">Email Address *</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={profile.email}
|
||||||
|
onChange={(e) => handleChange('email', e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="john@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="phone">Phone Number *</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
value={profile.phone}
|
||||||
|
onChange={(e) => handleChange('phone', e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="(555) 123-4567"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="city">City *</Label>
|
||||||
|
<Input
|
||||||
|
id="city"
|
||||||
|
value={profile.city}
|
||||||
|
onChange={(e) => handleChange('city', e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="San Francisco"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label htmlFor="address">Street Address</Label>
|
||||||
|
<Input
|
||||||
|
id="address"
|
||||||
|
value={profile.address}
|
||||||
|
onChange={(e) => handleChange('address', e.target.value)}
|
||||||
|
placeholder="123 Main St"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Employment Details */}
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3 mt-6">
|
||||||
|
<Briefcase className="w-4 h-4" />
|
||||||
|
<span>Employment Details</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="position">Position/Role *</Label>
|
||||||
|
<Input
|
||||||
|
id="position"
|
||||||
|
value={profile.position}
|
||||||
|
onChange={(e) => handleChange('position', e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="e.g., Server, Chef, Bartender"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="department">Department</Label>
|
||||||
|
<Select value={profile.department} onValueChange={(value) => handleChange('department', value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select department" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Operations">Operations</SelectItem>
|
||||||
|
<SelectItem value="Kitchen">Kitchen</SelectItem>
|
||||||
|
<SelectItem value="Service">Service</SelectItem>
|
||||||
|
<SelectItem value="Bar">Bar</SelectItem>
|
||||||
|
<SelectItem value="Events">Events</SelectItem>
|
||||||
|
<SelectItem value="Catering">Catering</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="employment_type">Employment Type *</Label>
|
||||||
|
<Select value={profile.employment_type} onValueChange={(value) => handleChange('employment_type', value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Full Time">Full Time</SelectItem>
|
||||||
|
<SelectItem value="Part Time">Part Time</SelectItem>
|
||||||
|
<SelectItem value="On call">On Call</SelectItem>
|
||||||
|
<SelectItem value="Seasonal">Seasonal</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="english_level">English Proficiency</Label>
|
||||||
|
<Select value={profile.english_level} onValueChange={(value) => handleChange('english_level', value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Fluent">Fluent</SelectItem>
|
||||||
|
<SelectItem value="Intermediate">Intermediate</SelectItem>
|
||||||
|
<SelectItem value="Basic">Basic</SelectItem>
|
||||||
|
<SelectItem value="None">None</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3 mt-6">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
<span>Work Location</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="hub_location">Preferred Hub/Location</Label>
|
||||||
|
<Input
|
||||||
|
id="hub_location"
|
||||||
|
value={profile.hub_location}
|
||||||
|
onChange={(e) => handleChange('hub_location', e.target.value)}
|
||||||
|
placeholder="e.g., Downtown SF, Bay Area"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button type="submit" className="bg-[#0A39DF]">
|
||||||
|
Continue to Documents
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
frontend-web/src/components/onboarding/TrainingStep.jsx
Normal file
173
frontend-web/src/components/onboarding/TrainingStep.jsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { CheckCircle, Circle, Play, BookOpen } from "lucide-react";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
const trainingModules = [
|
||||||
|
{
|
||||||
|
id: 'safety',
|
||||||
|
title: 'Workplace Safety',
|
||||||
|
duration: '15 min',
|
||||||
|
required: true,
|
||||||
|
description: 'Learn about workplace safety protocols and emergency procedures',
|
||||||
|
topics: ['Emergency exits', 'Fire safety', 'First aid basics', 'Reporting incidents'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hygiene',
|
||||||
|
title: 'Food Safety & Hygiene',
|
||||||
|
duration: '20 min',
|
||||||
|
required: true,
|
||||||
|
description: 'Essential food handling and hygiene standards',
|
||||||
|
topics: ['Handwashing', 'Cross-contamination', 'Temperature control', 'Storage guidelines'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'customer_service',
|
||||||
|
title: 'Customer Service Excellence',
|
||||||
|
duration: '10 min',
|
||||||
|
required: true,
|
||||||
|
description: 'Delivering outstanding service to clients and guests',
|
||||||
|
topics: ['Communication skills', 'Handling complaints', 'Professional etiquette', 'Teamwork'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'compliance',
|
||||||
|
title: 'Compliance & Policies',
|
||||||
|
duration: '12 min',
|
||||||
|
required: true,
|
||||||
|
description: 'Company policies and legal compliance requirements',
|
||||||
|
topics: ['Code of conduct', 'Anti-discrimination', 'Data privacy', 'Time tracking'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TrainingStep({ data, onNext, onBack }) {
|
||||||
|
const [training, setTraining] = useState(data || { completed: [], acknowledged: false });
|
||||||
|
|
||||||
|
const handleModuleComplete = (moduleId) => {
|
||||||
|
setTraining(prev => ({
|
||||||
|
...prev,
|
||||||
|
completed: prev.completed.includes(moduleId)
|
||||||
|
? prev.completed.filter(id => id !== moduleId)
|
||||||
|
: [...prev.completed, moduleId],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAcknowledge = (checked) => {
|
||||||
|
setTraining(prev => ({ ...prev, acknowledged: checked }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
const allRequired = trainingModules
|
||||||
|
.filter(m => m.required)
|
||||||
|
.every(m => training.completed.includes(m.id));
|
||||||
|
|
||||||
|
if (!allRequired || !training.acknowledged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onNext({ type: 'training', data: training });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isComplete = (moduleId) => training.completed.includes(moduleId);
|
||||||
|
const allRequiredComplete = trainingModules
|
||||||
|
.filter(m => m.required)
|
||||||
|
.every(m => training.completed.includes(m.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 mb-2">Compliance Training</h2>
|
||||||
|
<p className="text-sm text-slate-500">Complete required training modules to ensure readiness</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{trainingModules.map(module => (
|
||||||
|
<Card
|
||||||
|
key={module.id}
|
||||||
|
className={isComplete(module.id) ? "border-green-500 bg-green-50" : ""}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0 mt-1">
|
||||||
|
{isComplete(module.id) ? (
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Circle className="w-6 h-6 text-slate-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900">
|
||||||
|
{module.title}
|
||||||
|
{module.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-500">{module.duration} · {module.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="text-sm text-slate-600 mb-3 ml-4 space-y-1">
|
||||||
|
{module.topics.map((topic, idx) => (
|
||||||
|
<li key={idx} className="list-disc">{topic}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={isComplete(module.id) ? "outline" : "default"}
|
||||||
|
onClick={() => handleModuleComplete(module.id)}
|
||||||
|
className={isComplete(module.id) ? "" : "bg-[#0A39DF]"}
|
||||||
|
>
|
||||||
|
{isComplete(module.id) ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
Completed
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Start Training
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allRequiredComplete && (
|
||||||
|
<Card className="bg-blue-50 border-blue-200">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id="acknowledge"
|
||||||
|
checked={training.acknowledged}
|
||||||
|
onCheckedChange={handleAcknowledge}
|
||||||
|
/>
|
||||||
|
<label htmlFor="acknowledge" className="text-sm text-slate-700 cursor-pointer">
|
||||||
|
I acknowledge that I have completed the required training modules and understand the
|
||||||
|
policies, procedures, and safety guidelines outlined above. I agree to follow all
|
||||||
|
company policies and maintain compliance standards.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
|
<Button variant="outline" onClick={onBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!allRequiredComplete || !training.acknowledged}
|
||||||
|
className="bg-[#0A39DF]"
|
||||||
|
>
|
||||||
|
Complete Onboarding
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
frontend-web/src/components/orders/OrderStatusBadge.jsx
Normal file
149
frontend-web/src/components/orders/OrderStatusBadge.jsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Zap, Clock, AlertTriangle, CheckCircle, XCircle, Package } from "lucide-react";
|
||||||
|
|
||||||
|
// Comprehensive color coding system
|
||||||
|
export const ORDER_STATUSES = {
|
||||||
|
RAPID: {
|
||||||
|
color: "bg-red-600 text-white border-0",
|
||||||
|
dotColor: "bg-red-400",
|
||||||
|
icon: Zap,
|
||||||
|
label: "RAPID",
|
||||||
|
priority: 1,
|
||||||
|
description: "Must be filled immediately"
|
||||||
|
},
|
||||||
|
REQUESTED: {
|
||||||
|
color: "bg-yellow-500 text-white border-0",
|
||||||
|
dotColor: "bg-yellow-300",
|
||||||
|
icon: Clock,
|
||||||
|
label: "Requested",
|
||||||
|
priority: 2,
|
||||||
|
description: "Pending vendor review"
|
||||||
|
},
|
||||||
|
PARTIALLY_ASSIGNED: {
|
||||||
|
color: "bg-orange-500 text-white border-0",
|
||||||
|
dotColor: "bg-orange-300",
|
||||||
|
icon: AlertTriangle,
|
||||||
|
label: "Partially Assigned",
|
||||||
|
priority: 3,
|
||||||
|
description: "Missing staff"
|
||||||
|
},
|
||||||
|
FULLY_ASSIGNED: {
|
||||||
|
color: "bg-green-600 text-white border-0",
|
||||||
|
dotColor: "bg-green-400",
|
||||||
|
icon: CheckCircle,
|
||||||
|
label: "Fully Assigned",
|
||||||
|
priority: 4,
|
||||||
|
description: "All staff confirmed"
|
||||||
|
},
|
||||||
|
AT_RISK: {
|
||||||
|
color: "bg-purple-600 text-white border-0",
|
||||||
|
dotColor: "bg-purple-400",
|
||||||
|
icon: AlertTriangle,
|
||||||
|
label: "At Risk",
|
||||||
|
priority: 2,
|
||||||
|
description: "Workers not confirmed or declined"
|
||||||
|
},
|
||||||
|
COMPLETED: {
|
||||||
|
color: "bg-slate-400 text-white border-0",
|
||||||
|
dotColor: "bg-slate-300",
|
||||||
|
icon: CheckCircle,
|
||||||
|
label: "Completed",
|
||||||
|
priority: 5,
|
||||||
|
description: "Invoice and approval pending"
|
||||||
|
},
|
||||||
|
PERMANENT: {
|
||||||
|
color: "bg-purple-700 text-white border-0",
|
||||||
|
dotColor: "bg-purple-500",
|
||||||
|
icon: Package,
|
||||||
|
label: "Permanent",
|
||||||
|
priority: 3,
|
||||||
|
description: "Permanent staffing"
|
||||||
|
},
|
||||||
|
CANCELED: {
|
||||||
|
color: "bg-slate-500 text-white border-0",
|
||||||
|
dotColor: "bg-slate-300",
|
||||||
|
icon: XCircle,
|
||||||
|
label: "Canceled",
|
||||||
|
priority: 6,
|
||||||
|
description: "Order canceled"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getOrderStatus(order) {
|
||||||
|
// Check if RAPID
|
||||||
|
if (order.is_rapid || order.event_name?.includes("RAPID")) {
|
||||||
|
return ORDER_STATUSES.RAPID;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedCount = order.assigned_staff?.length || 0;
|
||||||
|
const requestedCount = order.requested || 0;
|
||||||
|
|
||||||
|
// Check completion status
|
||||||
|
if (order.status === "Completed") {
|
||||||
|
return ORDER_STATUSES.COMPLETED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === "Canceled") {
|
||||||
|
return ORDER_STATUSES.CANCELED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if permanent
|
||||||
|
if (order.contract_type === "Permanent" || order.event_type === "Permanent") {
|
||||||
|
return ORDER_STATUSES.PERMANENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check assignment status
|
||||||
|
if (requestedCount > 0) {
|
||||||
|
if (assignedCount >= requestedCount) {
|
||||||
|
return ORDER_STATUSES.FULLY_ASSIGNED;
|
||||||
|
} else if (assignedCount > 0) {
|
||||||
|
return ORDER_STATUSES.PARTIALLY_ASSIGNED;
|
||||||
|
} else {
|
||||||
|
return ORDER_STATUSES.REQUESTED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to requested
|
||||||
|
return ORDER_STATUSES.REQUESTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderStatusBadge({ order, size = "default", showIcon = true, showDot = true, className = "" }) {
|
||||||
|
const status = getOrderStatus(order);
|
||||||
|
const Icon = status.icon;
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "px-2 py-0.5 text-[10px]",
|
||||||
|
default: "px-3 py-1 text-xs",
|
||||||
|
lg: "px-4 py-1.5 text-sm"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className={`${status.color} ${sizeClasses[size]} font-semibold shadow-sm whitespace-nowrap flex items-center gap-1.5 ${className}`}
|
||||||
|
title={status.description}
|
||||||
|
>
|
||||||
|
{showDot && (
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full ${status.dotColor} animate-pulse`} />
|
||||||
|
)}
|
||||||
|
{showIcon && <Icon className={size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'} />}
|
||||||
|
{status.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to sort orders by priority
|
||||||
|
export function sortOrdersByPriority(orders) {
|
||||||
|
return [...orders].sort((a, b) => {
|
||||||
|
const statusA = getOrderStatus(a);
|
||||||
|
const statusB = getOrderStatus(b);
|
||||||
|
|
||||||
|
// First by priority
|
||||||
|
if (statusA.priority !== statusB.priority) {
|
||||||
|
return statusA.priority - statusB.priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then by date (most recent first)
|
||||||
|
return new Date(b.date || b.created_date) - new Date(a.date || a.created_date);
|
||||||
|
});
|
||||||
|
}
|
||||||
332
frontend-web/src/components/orders/RapidOrderChat.jsx
Normal file
332
frontend-web/src/components/orders/RapidOrderChat.jsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Zap, Send, Check, Edit3, MapPin, Clock, Users, AlertCircle, Sparkles } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
export default function RapidOrderChat({ onOrderCreated }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [conversation, setConversation] = useState([]);
|
||||||
|
const [detectedOrder, setDetectedOrder] = useState(null);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ['current-user-rapid'],
|
||||||
|
queryFn: () => base44.auth.me(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: businesses } = useQuery({
|
||||||
|
queryKey: ['user-businesses'],
|
||||||
|
queryFn: () => base44.entities.Business.filter({ contact_name: user?.full_name }),
|
||||||
|
enabled: !!user,
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRapidOrderMutation = useMutation({
|
||||||
|
mutationFn: (orderData) => base44.entities.Event.create(orderData),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ RAPID Order Created",
|
||||||
|
description: "Order sent to preferred vendor with priority notification",
|
||||||
|
});
|
||||||
|
if (onOrderCreated) onOrderCreated(data);
|
||||||
|
// Reset
|
||||||
|
setConversation([]);
|
||||||
|
setDetectedOrder(null);
|
||||||
|
setMessage("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const analyzeMessage = async (msg) => {
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
// Add user message to conversation
|
||||||
|
setConversation(prev => [...prev, { role: 'user', content: msg }]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use AI to parse the message
|
||||||
|
const response = await base44.integrations.Core.InvokeLLM({
|
||||||
|
prompt: `You are an order assistant. Analyze this message and extract order details:
|
||||||
|
|
||||||
|
Message: "${msg}"
|
||||||
|
Current user: ${user?.full_name}
|
||||||
|
User's locations: ${businesses.map(b => b.business_name).join(', ')}
|
||||||
|
|
||||||
|
Extract:
|
||||||
|
1. Urgency keywords (ASAP, today, emergency, call out, urgent, rapid, now)
|
||||||
|
2. Role/position needed (cook, bartender, server, dishwasher, etc.)
|
||||||
|
3. Number of staff (if mentioned)
|
||||||
|
4. Time frame (if mentioned)
|
||||||
|
5. Location (if mentioned, otherwise use first available location)
|
||||||
|
|
||||||
|
Return a concise summary.`,
|
||||||
|
response_json_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
is_urgent: { type: "boolean" },
|
||||||
|
role: { type: "string" },
|
||||||
|
count: { type: "number" },
|
||||||
|
location: { type: "string" },
|
||||||
|
time_mentioned: { type: "boolean" },
|
||||||
|
start_time: { type: "string" },
|
||||||
|
end_time: { type: "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = response;
|
||||||
|
const primaryLocation = businesses[0]?.business_name || "Primary Location";
|
||||||
|
|
||||||
|
const order = {
|
||||||
|
is_rapid: parsed.is_urgent || true,
|
||||||
|
role: parsed.role || "Staff Member",
|
||||||
|
count: parsed.count || 1,
|
||||||
|
location: parsed.location || primaryLocation,
|
||||||
|
start_time: parsed.start_time || "ASAP",
|
||||||
|
end_time: parsed.end_time || "End of shift",
|
||||||
|
business_name: primaryLocation,
|
||||||
|
hub: businesses[0]?.hub_building || "Main Hub"
|
||||||
|
};
|
||||||
|
|
||||||
|
setDetectedOrder(order);
|
||||||
|
|
||||||
|
// AI response
|
||||||
|
const aiMessage = `Is this a RAPID ORDER for **${order.count} ${order.role}${order.count > 1 ? 's' : ''}** at **${order.location}**?\n\nTime: ${order.start_time} → ${order.end_time}`;
|
||||||
|
|
||||||
|
setConversation(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: aiMessage,
|
||||||
|
showConfirm: true
|
||||||
|
}]);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
setConversation(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: "I couldn't process that. Please provide more details like: role needed, how many, and when."
|
||||||
|
}]);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = () => {
|
||||||
|
if (!message.trim()) return;
|
||||||
|
analyzeMessage(message);
|
||||||
|
setMessage("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmOrder = () => {
|
||||||
|
if (!detectedOrder) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const orderData = {
|
||||||
|
event_name: `RAPID: ${detectedOrder.count} ${detectedOrder.role}${detectedOrder.count > 1 ? 's' : ''}`,
|
||||||
|
is_rapid: true,
|
||||||
|
status: "Pending",
|
||||||
|
business_name: detectedOrder.business_name,
|
||||||
|
hub: detectedOrder.hub,
|
||||||
|
event_location: detectedOrder.location,
|
||||||
|
date: now.toISOString().split('T')[0],
|
||||||
|
requested: detectedOrder.count,
|
||||||
|
client_name: user?.full_name,
|
||||||
|
client_email: user?.email,
|
||||||
|
notes: `RAPID ORDER - ${detectedOrder.start_time} to ${detectedOrder.end_time}`,
|
||||||
|
shifts: [{
|
||||||
|
shift_name: "Emergency Shift",
|
||||||
|
roles: [{
|
||||||
|
role: detectedOrder.role,
|
||||||
|
count: detectedOrder.count,
|
||||||
|
start_time: "ASAP",
|
||||||
|
end_time: "End of shift"
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
createRapidOrderMutation.mutate(orderData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditOrder = () => {
|
||||||
|
setConversation(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: "Please describe what you'd like to change."
|
||||||
|
}]);
|
||||||
|
setDetectedOrder(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gradient-to-br from-red-50 via-orange-50 to-yellow-50 border-2 border-red-300 shadow-xl">
|
||||||
|
<CardHeader className="border-b border-red-200 bg-white/50 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-orange-500 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<Zap className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle className="text-xl font-bold text-red-700 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5" />
|
||||||
|
RAPID Order Assistant
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-xs text-red-600 mt-1">Emergency staffing in minutes</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-red-600 text-white font-bold text-sm px-4 py-2 shadow-md animate-pulse">
|
||||||
|
URGENT
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-6">
|
||||||
|
{/* Chat Messages */}
|
||||||
|
<div className="space-y-4 mb-6 max-h-[400px] overflow-y-auto">
|
||||||
|
{conversation.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-red-500 to-orange-500 rounded-2xl flex items-center justify-center shadow-lg">
|
||||||
|
<Zap className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-lg text-slate-900 mb-2">Need staff urgently?</h3>
|
||||||
|
<p className="text-sm text-slate-600 mb-4">Just describe what you need, I'll handle the rest</p>
|
||||||
|
<div className="text-left max-w-md mx-auto space-y-2">
|
||||||
|
<div className="bg-white p-3 rounded-lg border border-slate-200 text-xs text-slate-600">
|
||||||
|
<strong>Example:</strong> "We had a call out. Need 2 cooks ASAP"
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg border border-slate-200 text-xs text-slate-600">
|
||||||
|
<strong>Example:</strong> "Emergency! Need bartender for tonight"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{conversation.map((msg, idx) => (
|
||||||
|
<motion.div
|
||||||
|
key={idx}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<div className={`max-w-[80%] ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-gradient-to-br from-blue-600 to-blue-700 text-white'
|
||||||
|
: 'bg-white border-2 border-red-200'
|
||||||
|
} rounded-2xl p-4 shadow-md`}>
|
||||||
|
{msg.role === 'assistant' && (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-6 h-6 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center">
|
||||||
|
<Sparkles className="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-red-600">AI Assistant</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className={`text-sm whitespace-pre-line ${msg.role === 'user' ? 'text-white' : 'text-slate-900'}`}>
|
||||||
|
{msg.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{msg.showConfirm && detectedOrder && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 p-3 bg-gradient-to-br from-slate-50 to-blue-50 rounded-lg border border-blue-200">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<Users className="w-4 h-4 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Staff Needed</p>
|
||||||
|
<p className="font-bold text-slate-900">{detectedOrder.count} {detectedOrder.role}{detectedOrder.count > 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<MapPin className="w-4 h-4 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Location</p>
|
||||||
|
<p className="font-bold text-slate-900">{detectedOrder.location}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs col-span-2">
|
||||||
|
<Clock className="w-4 h-4 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Time</p>
|
||||||
|
<p className="font-bold text-slate-900">{detectedOrder.start_time} → {detectedOrder.end_time}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmOrder}
|
||||||
|
disabled={createRapidOrderMutation.isPending}
|
||||||
|
className="flex-1 bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-lg"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 mr-2" />
|
||||||
|
{createRapidOrderMutation.isPending ? "Creating..." : "CONFIRM & SEND"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleEditOrder}
|
||||||
|
variant="outline"
|
||||||
|
className="border-2 border-red-300 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4 mr-2" />
|
||||||
|
EDIT
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{isProcessing && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="flex justify-start"
|
||||||
|
>
|
||||||
|
<div className="bg-white border-2 border-red-200 rounded-2xl p-4 shadow-md">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center animate-pulse">
|
||||||
|
<Sparkles className="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-slate-600">Processing your request...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
|
||||||
|
placeholder="Describe what you need... (e.g., 'Need 2 cooks ASAP')"
|
||||||
|
className="flex-1 border-2 border-red-300 focus:border-red-500 text-base"
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={!message.trim() || isProcessing}
|
||||||
|
className="bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-lg"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Helper Text */}
|
||||||
|
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-xs text-blue-800">
|
||||||
|
<strong>Tip:</strong> Include role, quantity, and urgency for fastest processing.
|
||||||
|
AI will auto-detect your location and send to your preferred vendor.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
374
frontend-web/src/components/orders/SmartAssignModal.jsx
Normal file
374
frontend-web/src/components/orders/SmartAssignModal.jsx
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Sparkles, Star, MapPin, Clock, Award, TrendingUp, AlertCircle, CheckCircle, Zap, Users, RefreshCw } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
|
||||||
|
export default function SmartAssignModal({ isOpen, onClose, event, roleNeeded, countNeeded }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedWorkers, setSelectedWorkers] = useState([]);
|
||||||
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
|
const [aiRecommendations, setAiRecommendations] = useState(null);
|
||||||
|
|
||||||
|
const { data: allStaff = [] } = useQuery({
|
||||||
|
queryKey: ['staff-smart-assign'],
|
||||||
|
queryFn: () => base44.entities.Staff.list(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: allEvents = [] } = useQuery({
|
||||||
|
queryKey: ['events-conflict-check'],
|
||||||
|
queryFn: () => base44.entities.Event.list(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Smart filtering
|
||||||
|
const eligibleStaff = useMemo(() => {
|
||||||
|
if (!event || !roleNeeded) return [];
|
||||||
|
|
||||||
|
return allStaff.filter(worker => {
|
||||||
|
// Role match
|
||||||
|
const hasRole = worker.position === roleNeeded ||
|
||||||
|
worker.position_2 === roleNeeded ||
|
||||||
|
worker.profile_type === "Cross-Trained";
|
||||||
|
|
||||||
|
// Availability check
|
||||||
|
const isAvailable = worker.employment_type !== "Medical Leave" &&
|
||||||
|
worker.action !== "Inactive";
|
||||||
|
|
||||||
|
// Conflict check - check if worker is already assigned
|
||||||
|
const eventDate = new Date(event.date);
|
||||||
|
const hasConflict = allEvents.some(e => {
|
||||||
|
if (e.id === event.id) return false;
|
||||||
|
const eDate = new Date(e.date);
|
||||||
|
return eDate.toDateString() === eventDate.toDateString() &&
|
||||||
|
e.assigned_staff?.some(s => s.staff_id === worker.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasRole && isAvailable && !hasConflict;
|
||||||
|
});
|
||||||
|
}, [allStaff, event, roleNeeded, allEvents]);
|
||||||
|
|
||||||
|
// Run AI analysis
|
||||||
|
const runSmartAnalysis = async () => {
|
||||||
|
setIsAnalyzing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prompt = `You are a workforce optimization AI. Analyze these workers and recommend the best ${countNeeded} for this job.
|
||||||
|
|
||||||
|
Event: ${event.event_name}
|
||||||
|
Location: ${event.event_location || event.hub}
|
||||||
|
Role Needed: ${roleNeeded}
|
||||||
|
Quantity: ${countNeeded}
|
||||||
|
|
||||||
|
Workers (JSON):
|
||||||
|
${JSON.stringify(eligibleStaff.map(w => ({
|
||||||
|
id: w.id,
|
||||||
|
name: w.employee_name,
|
||||||
|
rating: w.rating || 0,
|
||||||
|
reliability_score: w.reliability_score || 0,
|
||||||
|
total_shifts: w.total_shifts || 0,
|
||||||
|
no_show_count: w.no_show_count || 0,
|
||||||
|
position: w.position,
|
||||||
|
city: w.city,
|
||||||
|
profile_type: w.profile_type
|
||||||
|
})), null, 2)}
|
||||||
|
|
||||||
|
Rank them by:
|
||||||
|
1. Skills match (exact role match gets priority)
|
||||||
|
2. Rating (higher is better)
|
||||||
|
3. Reliability (lower no-shows, higher reliability score)
|
||||||
|
4. Experience (more shifts completed)
|
||||||
|
5. Distance (prefer closer to location)
|
||||||
|
|
||||||
|
Return the top ${countNeeded} worker IDs with brief reasoning.`;
|
||||||
|
|
||||||
|
const response = await base44.integrations.Core.InvokeLLM({
|
||||||
|
prompt,
|
||||||
|
response_json_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
recommendations: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
worker_id: { type: "string" },
|
||||||
|
reason: { type: "string" },
|
||||||
|
score: { type: "number" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const recommended = response.recommendations.map(rec => {
|
||||||
|
const worker = eligibleStaff.find(w => w.id === rec.worker_id);
|
||||||
|
return worker ? { ...worker, ai_reason: rec.reason, ai_score: rec.score } : null;
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
setAiRecommendations(recommended);
|
||||||
|
setSelectedWorkers(recommended.slice(0, countNeeded));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "✨ AI Analysis Complete",
|
||||||
|
description: `Found ${recommended.length} optimal matches`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Analysis Failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsAnalyzing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const assigned_staff = selectedWorkers.map(w => ({
|
||||||
|
staff_id: w.id,
|
||||||
|
staff_name: w.employee_name,
|
||||||
|
role: roleNeeded
|
||||||
|
}));
|
||||||
|
|
||||||
|
return base44.entities.Event.update(event.id, {
|
||||||
|
assigned_staff: [...(event.assigned_staff || []), ...assigned_staff],
|
||||||
|
status: "Confirmed"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Staff Assigned Successfully",
|
||||||
|
description: `${selectedWorkers.length} workers assigned to ${event.event_name}`,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen && eligibleStaff.length > 0 && !aiRecommendations) {
|
||||||
|
runSmartAnalysis();
|
||||||
|
}
|
||||||
|
}, [isOpen, eligibleStaff.length]);
|
||||||
|
|
||||||
|
const toggleWorker = (worker) => {
|
||||||
|
setSelectedWorkers(prev => {
|
||||||
|
const exists = prev.find(w => w.id === worker.id);
|
||||||
|
if (exists) {
|
||||||
|
return prev.filter(w => w.id !== worker.id);
|
||||||
|
} else if (prev.length < countNeeded) {
|
||||||
|
return [...prev, worker];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-3 text-2xl">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<Sparkles className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-900">Smart Assign (AI Assisted)</span>
|
||||||
|
<p className="text-sm text-slate-600 font-normal mt-1">
|
||||||
|
AI selected the best {countNeeded} {roleNeeded}{countNeeded > 1 ? 's' : ''} for this event
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isAnalyzing ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-2xl flex items-center justify-center animate-pulse">
|
||||||
|
<Sparkles className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-lg text-slate-900 mb-2">Analyzing workforce...</h3>
|
||||||
|
<p className="text-sm text-slate-600">AI is finding the optimal matches based on skills, ratings, and availability</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||||
|
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-blue-200">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Users className="w-8 h-8 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-blue-700 mb-1">Selected</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-900">{selectedWorkers.length}/{countNeeded}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-br from-purple-50 to-pink-50 border-2 border-purple-200">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Zap className="w-8 h-8 text-purple-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-purple-700 mb-1">Avg Rating</p>
|
||||||
|
<p className="text-2xl font-bold text-purple-900">
|
||||||
|
{selectedWorkers.length > 0
|
||||||
|
? (selectedWorkers.reduce((sum, w) => sum + (w.rating || 0), 0) / selectedWorkers.length).toFixed(1)
|
||||||
|
: "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-200">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-green-700 mb-1">Available</p>
|
||||||
|
<p className="text-2xl font-bold text-green-900">{eligibleStaff.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Recommendations */}
|
||||||
|
{aiRecommendations && aiRecommendations.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-bold text-slate-900">AI Recommendations</h3>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={runSmartAnalysis}
|
||||||
|
className="border-purple-300 hover:bg-purple-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Re-analyze
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiRecommendations.map((worker, idx) => {
|
||||||
|
const isSelected = selectedWorkers.some(w => w.id === worker.id);
|
||||||
|
const isOverLimit = selectedWorkers.length >= countNeeded && !isSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={worker.id}
|
||||||
|
className={`transition-all cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-gradient-to-br from-purple-50 to-indigo-50 border-2 border-purple-400 shadow-lg'
|
||||||
|
: 'bg-white border border-slate-200 hover:border-purple-300 hover:shadow-md'
|
||||||
|
} ${isOverLimit ? 'opacity-50' : ''}`}
|
||||||
|
onClick={() => !isOverLimit && toggleWorker(worker)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3 flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar className="w-12 h-12 border-2 border-purple-300">
|
||||||
|
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-indigo-600 text-white font-bold">
|
||||||
|
{worker.employee_name?.charAt(0) || 'W'}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
{idx === 0 && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-6 h-6 bg-yellow-500 rounded-full flex items-center justify-center shadow-md">
|
||||||
|
<Award className="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h4 className="font-bold text-slate-900">{worker.employee_name}</h4>
|
||||||
|
{idx === 0 && (
|
||||||
|
<Badge className="bg-gradient-to-r from-yellow-500 to-orange-500 text-white text-xs">
|
||||||
|
Top Pick
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{worker.position}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mb-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
|
||||||
|
<span className="text-sm font-bold text-slate-900">{worker.rating?.toFixed(1) || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-sm text-slate-600">
|
||||||
|
<TrendingUp className="w-4 h-4 text-green-600" />
|
||||||
|
{worker.total_shifts || 0} shifts
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-sm text-slate-600">
|
||||||
|
<MapPin className="w-4 h-4 text-blue-600" />
|
||||||
|
{worker.city || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{worker.ai_reason && (
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-2 mt-2">
|
||||||
|
<p className="text-xs text-purple-900">
|
||||||
|
<strong className="text-purple-700">AI Insight:</strong> {worker.ai_reason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
{worker.ai_score && (
|
||||||
|
<Badge className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white font-bold">
|
||||||
|
{Math.round(worker.ai_score)}/100
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isSelected && (
|
||||||
|
<CheckCircle className="w-6 h-6 text-purple-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<AlertCircle className="w-12 h-12 mx-auto mb-3 text-slate-400" />
|
||||||
|
<p className="text-slate-600">No eligible staff found for this role</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => assignMutation.mutate()}
|
||||||
|
disabled={selectedWorkers.length === 0 || assignMutation.isPending}
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-bold"
|
||||||
|
>
|
||||||
|
{assignMutation.isPending ? "Assigning..." : `Assign ${selectedWorkers.length} Workers`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
frontend-web/src/components/orders/WorkerConfirmationCard.jsx
Normal file
150
frontend-web/src/components/orders/WorkerConfirmationCard.jsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { CheckCircle, XCircle, Clock, MapPin, Calendar, AlertTriangle, RefreshCw, Info } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
export default function WorkerConfirmationCard({ assignment, event }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const confirmMutation = useMutation({
|
||||||
|
mutationFn: async (status) => {
|
||||||
|
return base44.entities.Assignment.update(assignment.id, {
|
||||||
|
assignment_status: status,
|
||||||
|
confirmed_date: new Date().toISOString()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (_, status) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['assignments'] });
|
||||||
|
toast({
|
||||||
|
title: status === "Confirmed" ? "✅ Shift Confirmed" : "❌ Shift Declined",
|
||||||
|
description: status === "Confirmed"
|
||||||
|
? "You're all set! See you at the event."
|
||||||
|
: "Notified vendor. They'll find a replacement.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
switch (assignment.assignment_status) {
|
||||||
|
case "Confirmed":
|
||||||
|
return "bg-green-100 text-green-700 border-green-300";
|
||||||
|
case "Cancelled":
|
||||||
|
return "bg-red-100 text-red-700 border-red-300";
|
||||||
|
case "Pending":
|
||||||
|
return "bg-yellow-100 text-yellow-700 border-yellow-300";
|
||||||
|
default:
|
||||||
|
return "bg-slate-100 text-slate-700 border-slate-300";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white border-2 border-slate-200 hover:border-blue-300 hover:shadow-lg transition-all">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h3 className="font-bold text-lg text-slate-900">{event.event_name}</h3>
|
||||||
|
{event.is_rapid && (
|
||||||
|
<Badge className="bg-red-600 text-white font-bold text-xs">
|
||||||
|
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||||
|
RAPID
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600">{assignment.role}</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={`border-2 font-semibold ${getStatusColor()}`}>
|
||||||
|
{assignment.assignment_status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Calendar className="w-4 h-4 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Date</p>
|
||||||
|
<p className="font-semibold text-slate-900">
|
||||||
|
{event.date ? format(new Date(event.date), "MMM d, yyyy") : "TBD"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="w-4 h-4 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Time</p>
|
||||||
|
<p className="font-semibold text-slate-900">
|
||||||
|
{assignment.scheduled_start ? format(new Date(assignment.scheduled_start), "h:mm a") : "ASAP"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm col-span-2">
|
||||||
|
<MapPin className="w-4 h-4 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Location</p>
|
||||||
|
<p className="font-semibold text-slate-900">{event.event_location || event.hub}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shift Details */}
|
||||||
|
{event.shifts?.[0] && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Info className="w-4 h-4 text-blue-600" />
|
||||||
|
<span className="text-xs font-bold text-blue-900">Shift Details</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-xs text-slate-700">
|
||||||
|
{event.shifts[0].uniform_type && (
|
||||||
|
<p><strong>Attire:</strong> {event.shifts[0].uniform_type}</p>
|
||||||
|
)}
|
||||||
|
{event.addons?.meal_provided && (
|
||||||
|
<p><strong>Meal:</strong> Provided</p>
|
||||||
|
)}
|
||||||
|
{event.notes && (
|
||||||
|
<p><strong>Notes:</strong> {event.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
{assignment.assignment_status === "Pending" && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => confirmMutation.mutate("Confirmed")}
|
||||||
|
disabled={confirmMutation.isPending}
|
||||||
|
className="flex-1 bg-green-600 hover:bg-green-700 text-white font-bold"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
Accept Shift
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => confirmMutation.mutate("Cancelled")}
|
||||||
|
disabled={confirmMutation.isPending}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 border-2 border-red-300 text-red-600 hover:bg-red-50 font-bold"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4 mr-2" />
|
||||||
|
Decline
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{assignment.assignment_status === "Confirmed" && (
|
||||||
|
<div className="bg-green-50 border-2 border-green-300 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 text-green-700">
|
||||||
|
<CheckCircle className="w-5 h-5" />
|
||||||
|
<span className="font-bold text-sm">You're confirmed for this shift!</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
frontend-web/src/components/reports/ClientTrendsReport.jsx
Normal file
202
frontend-web/src/components/reports/ClientTrendsReport.jsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Download, TrendingUp, Users, Star } from "lucide-react";
|
||||||
|
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
export default function ClientTrendsReport({ events, invoices }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Bookings by month
|
||||||
|
const bookingsByMonth = events.reduce((acc, event) => {
|
||||||
|
if (!event.date) return acc;
|
||||||
|
const date = new Date(event.date);
|
||||||
|
const month = date.toLocaleString('default', { month: 'short' });
|
||||||
|
acc[month] = (acc[month] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const monthlyBookings = Object.entries(bookingsByMonth).map(([month, count]) => ({
|
||||||
|
month,
|
||||||
|
bookings: count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Top clients by booking count
|
||||||
|
const clientBookings = events.reduce((acc, event) => {
|
||||||
|
const client = event.business_name || 'Unknown';
|
||||||
|
if (!acc[client]) {
|
||||||
|
acc[client] = { name: client, bookings: 0, revenue: 0 };
|
||||||
|
}
|
||||||
|
acc[client].bookings += 1;
|
||||||
|
acc[client].revenue += event.total || 0;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const topClients = Object.values(clientBookings)
|
||||||
|
.sort((a, b) => b.bookings - a.bookings)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
// Client satisfaction (mock data - would come from feedback)
|
||||||
|
const avgSatisfaction = 4.6;
|
||||||
|
const totalClients = new Set(events.map(e => e.business_name).filter(Boolean)).size;
|
||||||
|
const repeatRate = ((events.filter(e => e.is_recurring).length / events.length) * 100).toFixed(1);
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const csv = [
|
||||||
|
['Client Trends Report'],
|
||||||
|
['Generated', new Date().toISOString()],
|
||||||
|
[''],
|
||||||
|
['Summary'],
|
||||||
|
['Total Clients', totalClients],
|
||||||
|
['Average Satisfaction', avgSatisfaction],
|
||||||
|
['Repeat Booking Rate', `${repeatRate}%`],
|
||||||
|
[''],
|
||||||
|
['Top Clients'],
|
||||||
|
['Client Name', 'Bookings', 'Revenue'],
|
||||||
|
...topClients.map(c => [c.name, c.bookings, c.revenue.toFixed(2)]),
|
||||||
|
[''],
|
||||||
|
['Monthly Bookings'],
|
||||||
|
['Month', 'Bookings'],
|
||||||
|
...monthlyBookings.map(m => [m.month, m.bookings]),
|
||||||
|
].map(row => row.join(',')).join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `client-trends-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast({ title: "✅ Report Exported", description: "Client trends report downloaded as CSV" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">Client Satisfaction & Booking Trends</h2>
|
||||||
|
<p className="text-sm text-slate-500">Track client engagement and satisfaction metrics</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleExport} variant="outline">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Total Clients</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{totalClients}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<Users className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Avg Satisfaction</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{avgSatisfaction}/5</p>
|
||||||
|
<div className="flex gap-0.5 mt-1">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Star key={i} className={`w-4 h-4 ${i < Math.floor(avgSatisfaction) ? 'fill-amber-400 text-amber-400' : 'text-slate-300'}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center">
|
||||||
|
<Star className="w-6 h-6 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Repeat Rate</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{repeatRate}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-6 h-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monthly Booking Trend */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Booking Trend Over Time</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={monthlyBookings}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Line type="monotone" dataKey="bookings" stroke="#0A39DF" strokeWidth={2} name="Bookings" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Top Clients */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top Clients by Bookings</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<BarChart data={topClients} layout="vertical">
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis type="number" />
|
||||||
|
<YAxis dataKey="name" type="category" width={150} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="bookings" fill="#0A39DF" name="Bookings" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Client List */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Client Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{topClients.map((client, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-900">{client.name}</p>
|
||||||
|
<p className="text-sm text-slate-500">{client.bookings} bookings</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="font-semibold">
|
||||||
|
${client.revenue.toLocaleString()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
333
frontend-web/src/components/reports/CustomReportBuilder.jsx
Normal file
333
frontend-web/src/components/reports/CustomReportBuilder.jsx
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Download, Plus, X } from "lucide-react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
export default function CustomReportBuilder({ events, staff, invoices }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [reportConfig, setReportConfig] = useState({
|
||||||
|
name: "",
|
||||||
|
dataSource: "events",
|
||||||
|
dateRange: "30",
|
||||||
|
fields: [],
|
||||||
|
filters: [],
|
||||||
|
groupBy: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataSourceFields = {
|
||||||
|
events: ['event_name', 'business_name', 'status', 'date', 'total', 'requested', 'hub'],
|
||||||
|
staff: ['employee_name', 'position', 'department', 'hub_location', 'rating', 'reliability_score'],
|
||||||
|
invoices: ['invoice_number', 'business_name', 'amount', 'status', 'issue_date', 'due_date'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFieldToggle = (field) => {
|
||||||
|
setReportConfig(prev => ({
|
||||||
|
...prev,
|
||||||
|
fields: prev.fields.includes(field)
|
||||||
|
? prev.fields.filter(f => f !== field)
|
||||||
|
: [...prev.fields, field],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateReport = () => {
|
||||||
|
if (!reportConfig.name || reportConfig.fields.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "⚠️ Incomplete Configuration",
|
||||||
|
description: "Please provide a report name and select at least one field.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get data based on source
|
||||||
|
let data = [];
|
||||||
|
if (reportConfig.dataSource === 'events') data = events;
|
||||||
|
else if (reportConfig.dataSource === 'staff') data = staff;
|
||||||
|
else if (reportConfig.dataSource === 'invoices') data = invoices;
|
||||||
|
|
||||||
|
// Filter data by selected fields
|
||||||
|
const filteredData = data.map(item => {
|
||||||
|
const filtered = {};
|
||||||
|
reportConfig.fields.forEach(field => {
|
||||||
|
filtered[field] = item[field] || '-';
|
||||||
|
});
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate CSV
|
||||||
|
const headers = reportConfig.fields.join(',');
|
||||||
|
const rows = filteredData.map(item =>
|
||||||
|
reportConfig.fields.map(field => `"${item[field]}"`).join(',')
|
||||||
|
);
|
||||||
|
const csv = [headers, ...rows].join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${reportConfig.name.replace(/\s+/g, '-').toLowerCase()}-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "✅ Report Generated",
|
||||||
|
description: `${reportConfig.name} has been exported successfully.`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportJSON = () => {
|
||||||
|
if (!reportConfig.name || reportConfig.fields.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "⚠️ Incomplete Configuration",
|
||||||
|
description: "Please provide a report name and select at least one field.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = [];
|
||||||
|
if (reportConfig.dataSource === 'events') data = events;
|
||||||
|
else if (reportConfig.dataSource === 'staff') data = staff;
|
||||||
|
else if (reportConfig.dataSource === 'invoices') data = invoices;
|
||||||
|
|
||||||
|
const filteredData = data.map(item => {
|
||||||
|
const filtered = {};
|
||||||
|
reportConfig.fields.forEach(field => {
|
||||||
|
filtered[field] = item[field] || null;
|
||||||
|
});
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
|
||||||
|
const jsonData = {
|
||||||
|
reportName: reportConfig.name,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
dataSource: reportConfig.dataSource,
|
||||||
|
recordCount: filteredData.length,
|
||||||
|
data: filteredData,
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${reportConfig.name.replace(/\s+/g, '-').toLowerCase()}-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "✅ JSON Exported",
|
||||||
|
description: `${reportConfig.name} exported as JSON.`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableFields = dataSourceFields[reportConfig.dataSource] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">Custom Report Builder</h2>
|
||||||
|
<p className="text-sm text-slate-500">Create custom reports with selected fields and filters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Configuration Panel */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Report Configuration</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Report Name</Label>
|
||||||
|
<Input
|
||||||
|
value={reportConfig.name}
|
||||||
|
onChange={(e) => setReportConfig(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="e.g., Monthly Performance Report"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Data Source</Label>
|
||||||
|
<Select
|
||||||
|
value={reportConfig.dataSource}
|
||||||
|
onValueChange={(value) => setReportConfig(prev => ({ ...prev, dataSource: value, fields: [] }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="events">Events</SelectItem>
|
||||||
|
<SelectItem value="staff">Staff</SelectItem>
|
||||||
|
<SelectItem value="invoices">Invoices</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Date Range</Label>
|
||||||
|
<Select
|
||||||
|
value={reportConfig.dateRange}
|
||||||
|
onValueChange={(value) => setReportConfig(prev => ({ ...prev, dateRange: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7">Last 7 days</SelectItem>
|
||||||
|
<SelectItem value="30">Last 30 days</SelectItem>
|
||||||
|
<SelectItem value="90">Last 90 days</SelectItem>
|
||||||
|
<SelectItem value="365">Last year</SelectItem>
|
||||||
|
<SelectItem value="all">All time</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="mb-3 block">Select Fields to Include</Label>
|
||||||
|
<div className="space-y-2 max-h-60 overflow-y-auto border border-slate-200 rounded-lg p-3">
|
||||||
|
{availableFields.map(field => (
|
||||||
|
<div key={field} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={field}
|
||||||
|
checked={reportConfig.fields.includes(field)}
|
||||||
|
onCheckedChange={() => handleFieldToggle(field)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={field} className="cursor-pointer text-sm">
|
||||||
|
{field.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Preview Panel */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Report Preview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{reportConfig.name && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-500">Report Name</Label>
|
||||||
|
<p className="font-semibold text-slate-900">{reportConfig.name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-500">Data Source</Label>
|
||||||
|
<Badge variant="outline" className="mt-1">
|
||||||
|
{reportConfig.dataSource.charAt(0).toUpperCase() + reportConfig.dataSource.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reportConfig.fields.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-500 mb-2 block">Selected Fields ({reportConfig.fields.length})</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{reportConfig.fields.map(field => (
|
||||||
|
<Badge key={field} className="bg-blue-100 text-blue-700">
|
||||||
|
{field.replace(/_/g, ' ')}
|
||||||
|
<button
|
||||||
|
onClick={() => handleFieldToggle(field)}
|
||||||
|
className="ml-1 hover:text-blue-900"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-4 border-t space-y-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerateReport}
|
||||||
|
className="w-full bg-[#0A39DF]"
|
||||||
|
disabled={!reportConfig.name || reportConfig.fields.length === 0}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export as CSV
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleExportJSON}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!reportConfig.name || reportConfig.fields.length === 0}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export as JSON
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Saved Report Templates */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quick Templates</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start"
|
||||||
|
onClick={() => setReportConfig({
|
||||||
|
name: "Staff Performance Summary",
|
||||||
|
dataSource: "staff",
|
||||||
|
dateRange: "30",
|
||||||
|
fields: ['employee_name', 'position', 'rating', 'reliability_score'],
|
||||||
|
filters: [],
|
||||||
|
groupBy: "",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Staff Performance
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start"
|
||||||
|
onClick={() => setReportConfig({
|
||||||
|
name: "Event Cost Summary",
|
||||||
|
dataSource: "events",
|
||||||
|
dateRange: "90",
|
||||||
|
fields: ['event_name', 'business_name', 'date', 'total', 'status'],
|
||||||
|
filters: [],
|
||||||
|
groupBy: "",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Event Costs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start"
|
||||||
|
onClick={() => setReportConfig({
|
||||||
|
name: "Invoice Status Report",
|
||||||
|
dataSource: "invoices",
|
||||||
|
dateRange: "30",
|
||||||
|
fields: ['invoice_number', 'business_name', 'amount', 'status', 'due_date'],
|
||||||
|
filters: [],
|
||||||
|
groupBy: "",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Invoice Status
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Download, Zap, Clock, TrendingUp, CheckCircle } from "lucide-react";
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from "recharts";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444'];
|
||||||
|
|
||||||
|
export default function OperationalEfficiencyReport({ events, staff }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Automation impact metrics
|
||||||
|
const totalEvents = events.length;
|
||||||
|
const autoAssignedEvents = events.filter(e =>
|
||||||
|
e.assigned_staff && e.assigned_staff.length > 0
|
||||||
|
).length;
|
||||||
|
const automationRate = totalEvents > 0 ? ((autoAssignedEvents / totalEvents) * 100).toFixed(1) : 0;
|
||||||
|
|
||||||
|
// Fill rate by status
|
||||||
|
const statusBreakdown = events.reduce((acc, event) => {
|
||||||
|
const status = event.status || 'Draft';
|
||||||
|
acc[status] = (acc[status] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const statusData = Object.entries(statusBreakdown).map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Time to fill metrics
|
||||||
|
const avgTimeToFill = 2.3; // Mock - would calculate from event creation to full assignment
|
||||||
|
const avgResponseTime = 1.5; // Mock - hours to respond to requests
|
||||||
|
|
||||||
|
// Efficiency over time
|
||||||
|
const efficiencyTrend = [
|
||||||
|
{ month: 'Jan', automation: 75, fillRate: 88, responseTime: 2.1 },
|
||||||
|
{ month: 'Feb', automation: 78, fillRate: 90, responseTime: 1.9 },
|
||||||
|
{ month: 'Mar', automation: 82, fillRate: 92, responseTime: 1.7 },
|
||||||
|
{ month: 'Apr', automation: 85, fillRate: 94, responseTime: 1.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const csv = [
|
||||||
|
['Operational Efficiency Report'],
|
||||||
|
['Generated', new Date().toISOString()],
|
||||||
|
[''],
|
||||||
|
['Summary Metrics'],
|
||||||
|
['Total Events', totalEvents],
|
||||||
|
['Auto-Assigned Events', autoAssignedEvents],
|
||||||
|
['Automation Rate', `${automationRate}%`],
|
||||||
|
['Avg Time to Fill (hours)', avgTimeToFill],
|
||||||
|
['Avg Response Time (hours)', avgResponseTime],
|
||||||
|
[''],
|
||||||
|
['Status Breakdown'],
|
||||||
|
['Status', 'Count'],
|
||||||
|
...Object.entries(statusBreakdown).map(([status, count]) => [status, count]),
|
||||||
|
[''],
|
||||||
|
['Efficiency Trend'],
|
||||||
|
['Month', 'Automation %', 'Fill Rate %', 'Response Time (hrs)'],
|
||||||
|
...efficiencyTrend.map(t => [t.month, t.automation, t.fillRate, t.responseTime]),
|
||||||
|
].map(row => row.join(',')).join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `operational-efficiency-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast({ title: "✅ Report Exported", description: "Efficiency report downloaded as CSV" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">Operational Efficiency & Automation Impact</h2>
|
||||||
|
<p className="text-sm text-slate-500">Track process improvements and automation effectiveness</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleExport} variant="outline">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Automation Rate</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{automationRate}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||||
|
<Zap className="w-6 h-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Avg Time to Fill</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{avgTimeToFill}h</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<Clock className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Response Time</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{avgResponseTime}h</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-6 h-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Completed</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{events.filter(e => e.status === 'Completed').length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-emerald-100 rounded-full flex items-center justify-center">
|
||||||
|
<CheckCircle className="w-6 h-6 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Efficiency Trend */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Efficiency Metrics Over Time</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={efficiencyTrend}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="automation" fill="#a855f7" name="Automation %" />
|
||||||
|
<Bar dataKey="fillRate" fill="#3b82f6" name="Fill Rate %" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Status Breakdown */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Event Status Distribution</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={statusData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{statusData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Key Performance Indicators</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">Manual Work Reduction</p>
|
||||||
|
<p className="text-2xl font-bold text-purple-700">85%</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-purple-600">Excellent</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">First-Time Fill Rate</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-700">92%</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-blue-600">Good</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">Staff Utilization</p>
|
||||||
|
<p className="text-2xl font-bold text-green-700">88%</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-green-600">Optimal</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-amber-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">Conflict Detection</p>
|
||||||
|
<p className="text-2xl font-bold text-amber-700">97%</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-amber-600">High</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
frontend-web/src/components/reports/StaffPerformanceReport.jsx
Normal file
226
frontend-web/src/components/reports/StaffPerformanceReport.jsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Download, Users, TrendingUp, Clock } from "lucide-react";
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
export default function StaffPerformanceReport({ staff, events }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Calculate staff metrics
|
||||||
|
const staffMetrics = staff.map(s => {
|
||||||
|
const assignments = events.filter(e =>
|
||||||
|
e.assigned_staff?.some(as => as.staff_id === s.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const completedShifts = assignments.filter(e => e.status === 'Completed').length;
|
||||||
|
const totalShifts = s.total_shifts || assignments.length || 1;
|
||||||
|
const fillRate = totalShifts > 0 ? ((completedShifts / totalShifts) * 100).toFixed(1) : 0;
|
||||||
|
const reliability = s.reliability_score || s.shift_coverage_percentage || 85;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
name: s.employee_name,
|
||||||
|
position: s.position,
|
||||||
|
totalShifts,
|
||||||
|
completedShifts,
|
||||||
|
fillRate: parseFloat(fillRate),
|
||||||
|
reliability,
|
||||||
|
rating: s.rating || 4.2,
|
||||||
|
cancellations: s.cancellation_count || 0,
|
||||||
|
noShows: s.no_show_count || 0,
|
||||||
|
};
|
||||||
|
}).sort((a, b) => b.reliability - a.reliability);
|
||||||
|
|
||||||
|
// Top performers
|
||||||
|
const topPerformers = staffMetrics.slice(0, 10);
|
||||||
|
|
||||||
|
// Fill rate distribution
|
||||||
|
const fillRateRanges = [
|
||||||
|
{ range: '90-100%', count: staffMetrics.filter(s => s.fillRate >= 90).length },
|
||||||
|
{ range: '80-89%', count: staffMetrics.filter(s => s.fillRate >= 80 && s.fillRate < 90).length },
|
||||||
|
{ range: '70-79%', count: staffMetrics.filter(s => s.fillRate >= 70 && s.fillRate < 80).length },
|
||||||
|
{ range: '60-69%', count: staffMetrics.filter(s => s.fillRate >= 60 && s.fillRate < 70).length },
|
||||||
|
{ range: '<60%', count: staffMetrics.filter(s => s.fillRate < 60).length },
|
||||||
|
];
|
||||||
|
|
||||||
|
const avgReliability = staffMetrics.reduce((sum, s) => sum + s.reliability, 0) / staffMetrics.length || 0;
|
||||||
|
const avgFillRate = staffMetrics.reduce((sum, s) => sum + s.fillRate, 0) / staffMetrics.length || 0;
|
||||||
|
const totalCancellations = staffMetrics.reduce((sum, s) => sum + s.cancellations, 0);
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const csv = [
|
||||||
|
['Staff Performance Report'],
|
||||||
|
['Generated', new Date().toISOString()],
|
||||||
|
[''],
|
||||||
|
['Summary'],
|
||||||
|
['Average Reliability', `${avgReliability.toFixed(1)}%`],
|
||||||
|
['Average Fill Rate', `${avgFillRate.toFixed(1)}%`],
|
||||||
|
['Total Cancellations', totalCancellations],
|
||||||
|
[''],
|
||||||
|
['Staff Details'],
|
||||||
|
['Name', 'Position', 'Total Shifts', 'Completed', 'Fill Rate', 'Reliability', 'Rating', 'Cancellations', 'No Shows'],
|
||||||
|
...staffMetrics.map(s => [
|
||||||
|
s.name,
|
||||||
|
s.position,
|
||||||
|
s.totalShifts,
|
||||||
|
s.completedShifts,
|
||||||
|
`${s.fillRate}%`,
|
||||||
|
`${s.reliability}%`,
|
||||||
|
s.rating,
|
||||||
|
s.cancellations,
|
||||||
|
s.noShows,
|
||||||
|
]),
|
||||||
|
].map(row => row.join(',')).join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `staff-performance-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast({ title: "✅ Report Exported", description: "Performance report downloaded as CSV" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">Staff Performance Metrics</h2>
|
||||||
|
<p className="text-sm text-slate-500">Reliability, fill rates, and performance tracking</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleExport} variant="outline">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Avg Reliability</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{avgReliability.toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-6 h-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Avg Fill Rate</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{avgFillRate.toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<Users className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Total Cancellations</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{totalCancellations}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<Clock className="w-6 h-6 text-red-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fill Rate Distribution */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Fill Rate Distribution</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={fillRateRanges}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="range" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="count" fill="#0A39DF" name="Staff Count" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Top Performers Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top Performers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Staff Member</TableHead>
|
||||||
|
<TableHead>Position</TableHead>
|
||||||
|
<TableHead className="text-center">Shifts</TableHead>
|
||||||
|
<TableHead className="text-center">Fill Rate</TableHead>
|
||||||
|
<TableHead className="text-center">Reliability</TableHead>
|
||||||
|
<TableHead className="text-center">Rating</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{topPerformers.map((staff) => (
|
||||||
|
<TableRow key={staff.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="w-8 h-8">
|
||||||
|
<AvatarFallback className="bg-blue-100 text-blue-700 text-xs">
|
||||||
|
{staff.name.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="font-medium">{staff.name}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-600">{staff.position}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge variant="outline">{staff.completedShifts}/{staff.totalShifts}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge className={
|
||||||
|
staff.fillRate >= 90 ? "bg-green-500" :
|
||||||
|
staff.fillRate >= 75 ? "bg-blue-500" : "bg-amber-500"
|
||||||
|
}>
|
||||||
|
{staff.fillRate}%
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge className="bg-purple-500">{staff.reliability}%</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge variant="outline">{staff.rating}/5</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
234
frontend-web/src/components/reports/StaffingCostReport.jsx
Normal file
234
frontend-web/src/components/reports/StaffingCostReport.jsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Download, DollarSign, TrendingUp, AlertCircle } from "lucide-react";
|
||||||
|
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
const COLORS = ['#0A39DF', '#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'];
|
||||||
|
|
||||||
|
export default function StaffingCostReport({ events, invoices }) {
|
||||||
|
const [dateRange, setDateRange] = useState("30");
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Calculate costs by month
|
||||||
|
const costsByMonth = events.reduce((acc, event) => {
|
||||||
|
if (!event.date || !event.total) return acc;
|
||||||
|
const date = new Date(event.date);
|
||||||
|
const month = date.toLocaleString('default', { month: 'short', year: '2-digit' });
|
||||||
|
acc[month] = (acc[month] || 0) + (event.total || 0);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const monthlyData = Object.entries(costsByMonth).map(([month, cost]) => ({
|
||||||
|
month,
|
||||||
|
cost: Math.round(cost),
|
||||||
|
budget: Math.round(cost * 1.1), // 10% buffer
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Costs by department
|
||||||
|
const costsByDepartment = events.reduce((acc, event) => {
|
||||||
|
event.shifts?.forEach(shift => {
|
||||||
|
shift.roles?.forEach(role => {
|
||||||
|
const dept = role.department || 'Unassigned';
|
||||||
|
acc[dept] = (acc[dept] || 0) + (role.total_value || 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const departmentData = Object.entries(costsByDepartment)
|
||||||
|
.map(([name, value]) => ({ name, value: Math.round(value) }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
// Budget adherence
|
||||||
|
const totalSpent = events.reduce((sum, e) => sum + (e.total || 0), 0);
|
||||||
|
const totalBudget = totalSpent * 1.15; // Assume 15% buffer
|
||||||
|
const adherence = totalBudget > 0 ? ((totalSpent / totalBudget) * 100).toFixed(1) : 0;
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const data = {
|
||||||
|
summary: {
|
||||||
|
totalSpent: totalSpent.toFixed(2),
|
||||||
|
totalBudget: totalBudget.toFixed(2),
|
||||||
|
adherence: `${adherence}%`,
|
||||||
|
},
|
||||||
|
monthlyBreakdown: monthlyData,
|
||||||
|
departmentBreakdown: departmentData,
|
||||||
|
};
|
||||||
|
|
||||||
|
const csv = [
|
||||||
|
['Staffing Cost Report'],
|
||||||
|
['Generated', new Date().toISOString()],
|
||||||
|
[''],
|
||||||
|
['Summary'],
|
||||||
|
['Total Spent', totalSpent.toFixed(2)],
|
||||||
|
['Total Budget', totalBudget.toFixed(2)],
|
||||||
|
['Budget Adherence', `${adherence}%`],
|
||||||
|
[''],
|
||||||
|
['Monthly Breakdown'],
|
||||||
|
['Month', 'Cost', 'Budget'],
|
||||||
|
...monthlyData.map(d => [d.month, d.cost, d.budget]),
|
||||||
|
[''],
|
||||||
|
['Department Breakdown'],
|
||||||
|
['Department', 'Cost'],
|
||||||
|
...departmentData.map(d => [d.name, d.value]),
|
||||||
|
].map(row => row.join(',')).join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `staffing-costs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast({ title: "✅ Report Exported", description: "Cost report downloaded as CSV" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">Staffing Costs & Budget Adherence</h2>
|
||||||
|
<p className="text-sm text-slate-500">Track spending and budget compliance</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={dateRange} onValueChange={setDateRange}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7">Last 7 days</SelectItem>
|
||||||
|
<SelectItem value="30">Last 30 days</SelectItem>
|
||||||
|
<SelectItem value="90">Last 90 days</SelectItem>
|
||||||
|
<SelectItem value="365">Last year</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={handleExport} variant="outline">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Total Spent</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">${totalSpent.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<DollarSign className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Budget</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">${totalBudget.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-6 h-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Budget Adherence</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{adherence}%</p>
|
||||||
|
<Badge className={adherence < 90 ? "bg-green-500" : adherence < 100 ? "bg-amber-500" : "bg-red-500"}>
|
||||||
|
{adherence < 90 ? "Under Budget" : adherence < 100 ? "On Track" : "Over Budget"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||||
|
<AlertCircle className="w-6 h-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monthly Cost Trend */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Monthly Cost Trend</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={monthlyData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip formatter={(value) => `$${value.toLocaleString()}`} />
|
||||||
|
<Legend />
|
||||||
|
<Line type="monotone" dataKey="cost" stroke="#0A39DF" strokeWidth={2} name="Actual Cost" />
|
||||||
|
<Line type="monotone" dataKey="budget" stroke="#10b981" strokeWidth={2} strokeDasharray="5 5" name="Budget" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Department Breakdown */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Costs by Department</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={departmentData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{departmentData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip formatter={(value) => `$${value.toLocaleString()}`} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Department Spending</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{departmentData.slice(0, 5).map((dept, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{dept.name}</span>
|
||||||
|
<Badge variant="outline">${dept.value.toLocaleString()}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
frontend-web/src/components/scheduling/AutomationEngine.jsx
Normal file
211
frontend-web/src/components/scheduling/AutomationEngine.jsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { hasTimeOverlap, checkDoubleBooking } from "./SmartAssignmentEngine";
|
||||||
|
import { format, addDays } from "date-fns";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automation Engine
|
||||||
|
* Handles background automations to reduce manual work
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function AutomationEngine() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { data: events } = useQuery({
|
||||||
|
queryKey: ['events-automation'],
|
||||||
|
queryFn: () => base44.entities.Event.list(),
|
||||||
|
initialData: [],
|
||||||
|
refetchInterval: 30000, // Check every 30s
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: allStaff } = useQuery({
|
||||||
|
queryKey: ['staff-automation'],
|
||||||
|
queryFn: () => base44.entities.Staff.list(),
|
||||||
|
initialData: [],
|
||||||
|
refetchInterval: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: existingInvoices } = useQuery({
|
||||||
|
queryKey: ['invoices-automation'],
|
||||||
|
queryFn: () => base44.entities.Invoice.list(),
|
||||||
|
initialData: [],
|
||||||
|
refetchInterval: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-create invoice when event is marked as Completed
|
||||||
|
useEffect(() => {
|
||||||
|
const autoCreateInvoices = async () => {
|
||||||
|
const completedEvents = events.filter(e =>
|
||||||
|
e.status === 'Completed' &&
|
||||||
|
!e.invoice_id &&
|
||||||
|
!existingInvoices.some(inv => inv.event_id === e.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const event of completedEvents) {
|
||||||
|
try {
|
||||||
|
const invoiceNumber = `INV-${format(new Date(), 'yyMMddHHmmss')}`;
|
||||||
|
const issueDate = format(new Date(), 'yyyy-MM-dd');
|
||||||
|
const dueDate = format(addDays(new Date(), 30), 'yyyy-MM-dd'); // Net 30
|
||||||
|
|
||||||
|
const invoice = await base44.entities.Invoice.create({
|
||||||
|
invoice_number: invoiceNumber,
|
||||||
|
event_id: event.id,
|
||||||
|
event_name: event.event_name,
|
||||||
|
business_name: event.business_name || event.client_name,
|
||||||
|
vendor_name: event.vendor_name,
|
||||||
|
manager_name: event.client_name,
|
||||||
|
hub: event.hub,
|
||||||
|
cost_center: event.cost_center,
|
||||||
|
amount: event.total || 0,
|
||||||
|
item_count: event.assigned_staff?.length || 0,
|
||||||
|
status: 'Open',
|
||||||
|
issue_date: issueDate,
|
||||||
|
due_date: dueDate,
|
||||||
|
notes: `Auto-generated invoice for completed event: ${event.event_name}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update event with invoice_id
|
||||||
|
await base44.entities.Event.update(event.id, {
|
||||||
|
invoice_id: invoice.id
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto-invoice creation failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
autoCreateInvoices();
|
||||||
|
}
|
||||||
|
}, [events, existingInvoices, queryClient]);
|
||||||
|
|
||||||
|
// Auto-confirm workers (24 hours before shift)
|
||||||
|
useEffect(() => {
|
||||||
|
const autoConfirmWorkers = async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const upcomingEvents = events.filter(e => {
|
||||||
|
const eventDate = new Date(e.date);
|
||||||
|
return eventDate >= now && eventDate <= tomorrow && e.status === 'Assigned';
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const event of upcomingEvents) {
|
||||||
|
if (event.assigned_staff?.length > 0) {
|
||||||
|
try {
|
||||||
|
await base44.entities.Event.update(event.id, {
|
||||||
|
status: 'Confirmed'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send confirmation emails
|
||||||
|
for (const staff of event.assigned_staff) {
|
||||||
|
await base44.integrations.Core.SendEmail({
|
||||||
|
to: staff.email,
|
||||||
|
subject: `Shift Confirmed - ${event.event_name}`,
|
||||||
|
body: `Your shift at ${event.event_name} on ${event.date} has been confirmed. See you there!`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto-confirm failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
autoConfirmWorkers();
|
||||||
|
}
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
// Auto-send reminders (2 hours before shift)
|
||||||
|
useEffect(() => {
|
||||||
|
const sendReminders = async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const twoHoursLater = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const upcomingEvents = events.filter(e => {
|
||||||
|
const eventDate = new Date(e.date);
|
||||||
|
return eventDate >= now && eventDate <= twoHoursLater;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const event of upcomingEvents) {
|
||||||
|
if (event.assigned_staff?.length > 0 && event.status === 'Confirmed') {
|
||||||
|
for (const staff of event.assigned_staff) {
|
||||||
|
try {
|
||||||
|
await base44.integrations.Core.SendEmail({
|
||||||
|
to: staff.email,
|
||||||
|
subject: `Reminder: Your shift starts in 2 hours`,
|
||||||
|
body: `Reminder: Your shift at ${event.event_name} starts in 2 hours. Location: ${event.event_location || event.hub}`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Reminder failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
sendReminders();
|
||||||
|
}
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
// Auto-detect overlapping shifts
|
||||||
|
useEffect(() => {
|
||||||
|
const detectOverlaps = () => {
|
||||||
|
const conflicts = [];
|
||||||
|
|
||||||
|
allStaff.forEach(staff => {
|
||||||
|
const staffEvents = events.filter(e =>
|
||||||
|
e.assigned_staff?.some(s => s.staff_id === staff.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < staffEvents.length; i++) {
|
||||||
|
for (let j = i + 1; j < staffEvents.length; j++) {
|
||||||
|
const e1 = staffEvents[i];
|
||||||
|
const e2 = staffEvents[j];
|
||||||
|
|
||||||
|
const d1 = new Date(e1.date);
|
||||||
|
const d2 = new Date(e2.date);
|
||||||
|
|
||||||
|
if (d1.toDateString() === d2.toDateString()) {
|
||||||
|
const shift1 = e1.shifts?.[0]?.roles?.[0];
|
||||||
|
const shift2 = e2.shifts?.[0]?.roles?.[0];
|
||||||
|
|
||||||
|
if (shift1 && shift2 && hasTimeOverlap(shift1, shift2)) {
|
||||||
|
conflicts.push({
|
||||||
|
staff: staff.employee_name,
|
||||||
|
event1: e1.event_name,
|
||||||
|
event2: e2.event_name,
|
||||||
|
date: e1.date
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
toast({
|
||||||
|
title: `⚠️ ${conflicts.length} Double-Booking Detected`,
|
||||||
|
description: `${conflicts[0].staff} has overlapping shifts`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0 && allStaff.length > 0) {
|
||||||
|
detectOverlaps();
|
||||||
|
}
|
||||||
|
}, [events, allStaff]);
|
||||||
|
|
||||||
|
return null; // Background service
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AutomationEngine;
|
||||||
314
frontend-web/src/components/scheduling/ConflictDetection.jsx
Normal file
314
frontend-web/src/components/scheduling/ConflictDetection.jsx
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { format, parseISO, isWithinInterval, addMinutes, subMinutes } from "date-fns";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { AlertTriangle, X, Users, MapPin, Clock } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conflict Detection System
|
||||||
|
* Detects and alerts users to overlapping event bookings
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Parse time string (HH:MM or HH:MM AM/PM) to minutes since midnight
|
||||||
|
const parseTimeToMinutes = (timeStr) => {
|
||||||
|
if (!timeStr) return 0;
|
||||||
|
|
||||||
|
// Handle 24-hour format
|
||||||
|
if (timeStr.includes(':') && !timeStr.includes('AM') && !timeStr.includes('PM')) {
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 12-hour format
|
||||||
|
const [time, period] = timeStr.split(' ');
|
||||||
|
let [hours, minutes] = time.split(':').map(Number);
|
||||||
|
if (period === 'PM' && hours !== 12) hours += 12;
|
||||||
|
if (period === 'AM' && hours === 12) hours = 0;
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if two time ranges overlap (considering buffer)
|
||||||
|
export const detectTimeOverlap = (start1, end1, start2, end2, bufferMinutes = 0) => {
|
||||||
|
const s1 = parseTimeToMinutes(start1) - bufferMinutes;
|
||||||
|
const e1 = parseTimeToMinutes(end1) + bufferMinutes;
|
||||||
|
const s2 = parseTimeToMinutes(start2);
|
||||||
|
const e2 = parseTimeToMinutes(end2);
|
||||||
|
|
||||||
|
return s1 < e2 && s2 < e1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if two dates are the same or overlap (for multi-day events)
|
||||||
|
export const detectDateOverlap = (event1, event2) => {
|
||||||
|
const e1Start = event1.is_multi_day ? parseISO(event1.multi_day_start_date) : parseISO(event1.date);
|
||||||
|
const e1End = event1.is_multi_day ? parseISO(event1.multi_day_end_date) : parseISO(event1.date);
|
||||||
|
const e2Start = event2.is_multi_day ? parseISO(event2.multi_day_start_date) : parseISO(event2.date);
|
||||||
|
const e2End = event2.is_multi_day ? parseISO(event2.multi_day_end_date) : parseISO(event2.date);
|
||||||
|
|
||||||
|
return isWithinInterval(e1Start, { start: e2Start, end: e2End }) ||
|
||||||
|
isWithinInterval(e1End, { start: e2Start, end: e2End }) ||
|
||||||
|
isWithinInterval(e2Start, { start: e1Start, end: e1End }) ||
|
||||||
|
isWithinInterval(e2End, { start: e1Start, end: e1End });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect staff conflicts
|
||||||
|
export const detectStaffConflicts = (event, allEvents) => {
|
||||||
|
const conflicts = [];
|
||||||
|
|
||||||
|
if (!event.assigned_staff || event.assigned_staff.length === 0) {
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTimes = event.shifts?.[0]?.roles?.[0] || {};
|
||||||
|
const bufferBefore = event.buffer_time_before || 0;
|
||||||
|
const bufferAfter = event.buffer_time_after || 0;
|
||||||
|
|
||||||
|
for (const staff of event.assigned_staff) {
|
||||||
|
for (const otherEvent of allEvents) {
|
||||||
|
if (otherEvent.id === event.id) continue;
|
||||||
|
if (otherEvent.status === 'Canceled' || otherEvent.status === 'Completed') continue;
|
||||||
|
|
||||||
|
// Check if same staff is assigned
|
||||||
|
const staffInOther = otherEvent.assigned_staff?.find(s => s.staff_id === staff.staff_id);
|
||||||
|
if (!staffInOther) continue;
|
||||||
|
|
||||||
|
// Check date overlap
|
||||||
|
if (!detectDateOverlap(event, otherEvent)) continue;
|
||||||
|
|
||||||
|
// Check time overlap
|
||||||
|
const otherTimes = otherEvent.shifts?.[0]?.roles?.[0] || {};
|
||||||
|
const hasOverlap = detectTimeOverlap(
|
||||||
|
eventTimes.start_time,
|
||||||
|
eventTimes.end_time,
|
||||||
|
otherTimes.start_time,
|
||||||
|
otherTimes.end_time,
|
||||||
|
bufferBefore + bufferAfter
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasOverlap) {
|
||||||
|
conflicts.push({
|
||||||
|
conflict_type: 'staff_overlap',
|
||||||
|
severity: 'high',
|
||||||
|
description: `${staff.staff_name} is double-booked with "${otherEvent.event_name}"`,
|
||||||
|
conflicting_event_id: otherEvent.id,
|
||||||
|
conflicting_event_name: otherEvent.event_name,
|
||||||
|
staff_id: staff.staff_id,
|
||||||
|
staff_name: staff.staff_name,
|
||||||
|
detected_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect venue conflicts
|
||||||
|
export const detectVenueConflicts = (event, allEvents) => {
|
||||||
|
const conflicts = [];
|
||||||
|
|
||||||
|
if (!event.event_location && !event.hub) {
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventLocation = event.event_location || event.hub;
|
||||||
|
const eventTimes = event.shifts?.[0]?.roles?.[0] || {};
|
||||||
|
const bufferBefore = event.buffer_time_before || 0;
|
||||||
|
const bufferAfter = event.buffer_time_after || 0;
|
||||||
|
|
||||||
|
for (const otherEvent of allEvents) {
|
||||||
|
if (otherEvent.id === event.id) continue;
|
||||||
|
if (otherEvent.status === 'Canceled' || otherEvent.status === 'Completed') continue;
|
||||||
|
|
||||||
|
const otherLocation = otherEvent.event_location || otherEvent.hub;
|
||||||
|
if (!otherLocation) continue;
|
||||||
|
|
||||||
|
// Check if same location
|
||||||
|
if (eventLocation.toLowerCase() !== otherLocation.toLowerCase()) continue;
|
||||||
|
|
||||||
|
// Check date overlap
|
||||||
|
if (!detectDateOverlap(event, otherEvent)) continue;
|
||||||
|
|
||||||
|
// Check time overlap
|
||||||
|
const otherTimes = otherEvent.shifts?.[0]?.roles?.[0] || {};
|
||||||
|
const hasOverlap = detectTimeOverlap(
|
||||||
|
eventTimes.start_time,
|
||||||
|
eventTimes.end_time,
|
||||||
|
otherTimes.start_time,
|
||||||
|
otherTimes.end_time,
|
||||||
|
bufferBefore + bufferAfter
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasOverlap) {
|
||||||
|
conflicts.push({
|
||||||
|
conflict_type: 'venue_overlap',
|
||||||
|
severity: 'medium',
|
||||||
|
description: `Venue "${eventLocation}" is already booked for "${otherEvent.event_name}"`,
|
||||||
|
conflicting_event_id: otherEvent.id,
|
||||||
|
conflicting_event_name: otherEvent.event_name,
|
||||||
|
location: eventLocation,
|
||||||
|
detected_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect buffer time violations
|
||||||
|
export const detectBufferViolations = (event, allEvents) => {
|
||||||
|
const conflicts = [];
|
||||||
|
|
||||||
|
if (!event.buffer_time_before && !event.buffer_time_after) {
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTimes = event.shifts?.[0]?.roles?.[0] || {};
|
||||||
|
|
||||||
|
for (const otherEvent of allEvents) {
|
||||||
|
if (otherEvent.id === event.id) continue;
|
||||||
|
if (otherEvent.status === 'Canceled' || otherEvent.status === 'Completed') continue;
|
||||||
|
|
||||||
|
// Check if events share staff
|
||||||
|
const sharedStaff = event.assigned_staff?.filter(s =>
|
||||||
|
otherEvent.assigned_staff?.some(os => os.staff_id === s.staff_id)
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
if (sharedStaff.length === 0) continue;
|
||||||
|
|
||||||
|
// Check date overlap
|
||||||
|
if (!detectDateOverlap(event, otherEvent)) continue;
|
||||||
|
|
||||||
|
// Check if buffer time is violated
|
||||||
|
const otherTimes = otherEvent.shifts?.[0]?.roles?.[0] || {};
|
||||||
|
const eventStart = parseTimeToMinutes(eventTimes.start_time);
|
||||||
|
const eventEnd = parseTimeToMinutes(eventTimes.end_time);
|
||||||
|
const otherStart = parseTimeToMinutes(otherTimes.start_time);
|
||||||
|
const otherEnd = parseTimeToMinutes(otherTimes.end_time);
|
||||||
|
|
||||||
|
const bufferBefore = event.buffer_time_before || 0;
|
||||||
|
const bufferAfter = event.buffer_time_after || 0;
|
||||||
|
|
||||||
|
const hasViolation =
|
||||||
|
(otherEnd > eventStart - bufferBefore && otherEnd <= eventStart) ||
|
||||||
|
(otherStart < eventEnd + bufferAfter && otherStart >= eventEnd);
|
||||||
|
|
||||||
|
if (hasViolation) {
|
||||||
|
conflicts.push({
|
||||||
|
conflict_type: 'time_buffer',
|
||||||
|
severity: 'low',
|
||||||
|
description: `Buffer time violation with "${otherEvent.event_name}" (${sharedStaff.length} shared staff)`,
|
||||||
|
conflicting_event_id: otherEvent.id,
|
||||||
|
conflicting_event_name: otherEvent.event_name,
|
||||||
|
buffer_required: `${bufferBefore + bufferAfter} minutes`,
|
||||||
|
detected_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main conflict detection function
|
||||||
|
export const detectAllConflicts = (event, allEvents) => {
|
||||||
|
if (!event.conflict_detection_enabled) return [];
|
||||||
|
|
||||||
|
const staffConflicts = detectStaffConflicts(event, allEvents);
|
||||||
|
const venueConflicts = detectVenueConflicts(event, allEvents);
|
||||||
|
const bufferViolations = detectBufferViolations(event, allEvents);
|
||||||
|
|
||||||
|
return [...staffConflicts, ...venueConflicts, ...bufferViolations];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Conflict Alert Component
|
||||||
|
export function ConflictAlert({ conflicts, onDismiss }) {
|
||||||
|
if (!conflicts || conflicts.length === 0) return null;
|
||||||
|
|
||||||
|
const getSeverityColor = (severity) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical': return 'border-red-600 bg-red-50';
|
||||||
|
case 'high': return 'border-orange-500 bg-orange-50';
|
||||||
|
case 'medium': return 'border-amber-500 bg-amber-50';
|
||||||
|
case 'low': return 'border-blue-500 bg-blue-50';
|
||||||
|
default: return 'border-slate-300 bg-slate-50';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSeverityIcon = (severity) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical':
|
||||||
|
case 'high': return <AlertTriangle className="w-5 h-5 text-red-600" />;
|
||||||
|
case 'medium': return <AlertTriangle className="w-5 h-5 text-amber-600" />;
|
||||||
|
case 'low': return <Clock className="w-5 h-5 text-blue-600" />;
|
||||||
|
default: return <AlertTriangle className="w-5 h-5 text-slate-600" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConflictIcon = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'staff_overlap': return <Users className="w-4 h-4" />;
|
||||||
|
case 'venue_overlap': return <MapPin className="w-4 h-4" />;
|
||||||
|
case 'time_buffer': return <Clock className="w-4 h-4" />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{conflicts.map((conflict, idx) => (
|
||||||
|
<Alert key={idx} className={`${getSeverityColor(conflict.severity)} border-2 relative`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
{getSeverityIcon(conflict.severity)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{getConflictIcon(conflict.conflict_type)}
|
||||||
|
<Badge variant="outline" className="text-xs uppercase">
|
||||||
|
{conflict.conflict_type.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
<Badge className={`text-xs ${
|
||||||
|
conflict.severity === 'critical' || conflict.severity === 'high'
|
||||||
|
? 'bg-red-600 text-white'
|
||||||
|
: conflict.severity === 'medium'
|
||||||
|
? 'bg-amber-600 text-white'
|
||||||
|
: 'bg-blue-600 text-white'
|
||||||
|
}`}>
|
||||||
|
{conflict.severity.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<AlertDescription className="font-medium text-slate-900 text-sm">
|
||||||
|
{conflict.description}
|
||||||
|
</AlertDescription>
|
||||||
|
{conflict.buffer_required && (
|
||||||
|
<p className="text-xs text-slate-600 mt-1">
|
||||||
|
Buffer required: {conflict.buffer_required}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onDismiss && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 flex-shrink-0"
|
||||||
|
onClick={() => onDismiss(idx)}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
detectTimeOverlap,
|
||||||
|
detectDateOverlap,
|
||||||
|
detectStaffConflicts,
|
||||||
|
detectVenueConflicts,
|
||||||
|
detectBufferViolations,
|
||||||
|
detectAllConflicts,
|
||||||
|
ConflictAlert,
|
||||||
|
};
|
||||||
255
frontend-web/src/components/scheduling/DragDropScheduler.jsx
Normal file
255
frontend-web/src/components/scheduling/DragDropScheduler.jsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Calendar, Clock, MapPin, Star } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag & Drop Scheduler Widget
|
||||||
|
* Interactive visual scheduler for easy staff assignment
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default function DragDropScheduler({ events, staff, onAssign, onUnassign }) {
|
||||||
|
const [localEvents, setLocalEvents] = useState(events || []);
|
||||||
|
const [localStaff, setLocalStaff] = useState(staff || []);
|
||||||
|
|
||||||
|
const handleDragEnd = (result) => {
|
||||||
|
const { source, destination, draggableId } = result;
|
||||||
|
|
||||||
|
if (!destination) return;
|
||||||
|
|
||||||
|
// Dragging from unassigned to event
|
||||||
|
if (source.droppableId === "unassigned" && destination.droppableId.startsWith("event-")) {
|
||||||
|
const eventId = destination.droppableId.replace("event-", "");
|
||||||
|
const staffMember = localStaff.find(s => s.id === draggableId);
|
||||||
|
|
||||||
|
if (staffMember && onAssign) {
|
||||||
|
onAssign(eventId, staffMember);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setLocalStaff(prev => prev.filter(s => s.id !== draggableId));
|
||||||
|
setLocalEvents(prev => prev.map(e => {
|
||||||
|
if (e.id === eventId) {
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
assigned_staff: [...(e.assigned_staff || []), {
|
||||||
|
staff_id: staffMember.id,
|
||||||
|
staff_name: staffMember.employee_name,
|
||||||
|
email: staffMember.email,
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dragging from event back to unassigned
|
||||||
|
if (source.droppableId.startsWith("event-") && destination.droppableId === "unassigned") {
|
||||||
|
const eventId = source.droppableId.replace("event-", "");
|
||||||
|
const event = localEvents.find(e => e.id === eventId);
|
||||||
|
const staffMember = event?.assigned_staff?.find(s => s.staff_id === draggableId);
|
||||||
|
|
||||||
|
if (staffMember && onUnassign) {
|
||||||
|
onUnassign(eventId, draggableId);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setLocalEvents(prev => prev.map(e => {
|
||||||
|
if (e.id === eventId) {
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
assigned_staff: e.assigned_staff.filter(s => s.staff_id !== draggableId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fullStaff = staff.find(s => s.id === draggableId);
|
||||||
|
if (fullStaff) {
|
||||||
|
setLocalStaff(prev => [...prev, fullStaff]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dragging between events
|
||||||
|
if (source.droppableId.startsWith("event-") && destination.droppableId.startsWith("event-")) {
|
||||||
|
const sourceEventId = source.droppableId.replace("event-", "");
|
||||||
|
const destEventId = destination.droppableId.replace("event-", "");
|
||||||
|
|
||||||
|
if (sourceEventId === destEventId) return;
|
||||||
|
|
||||||
|
const sourceEvent = localEvents.find(e => e.id === sourceEventId);
|
||||||
|
const staffMember = sourceEvent?.assigned_staff?.find(s => s.staff_id === draggableId);
|
||||||
|
|
||||||
|
if (staffMember) {
|
||||||
|
onUnassign(sourceEventId, draggableId);
|
||||||
|
onAssign(destEventId, staff.find(s => s.id === draggableId));
|
||||||
|
|
||||||
|
setLocalEvents(prev => prev.map(e => {
|
||||||
|
if (e.id === sourceEventId) {
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
assigned_staff: e.assigned_staff.filter(s => s.staff_id !== draggableId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (e.id === destEventId) {
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
assigned_staff: [...(e.assigned_staff || []), staffMember]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Unassigned Staff Pool */}
|
||||||
|
<Card className="lg:col-span-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Available Staff</CardTitle>
|
||||||
|
<p className="text-sm text-slate-500">{localStaff.length} unassigned</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Droppable droppableId="unassigned">
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
className={`min-h-[400px] rounded-lg p-3 transition-colors ${
|
||||||
|
snapshot.isDraggingOver ? 'bg-blue-50 border-2 border-blue-300' : 'bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{localStaff.map((s, index) => (
|
||||||
|
<Draggable key={s.id} draggableId={s.id} index={index}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
className={`bg-white rounded-lg p-3 mb-2 border border-slate-200 shadow-sm ${
|
||||||
|
snapshot.isDragging ? 'shadow-lg ring-2 ring-blue-400' : 'hover:shadow-md'
|
||||||
|
} transition-all cursor-move`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="w-10 h-10">
|
||||||
|
<AvatarFallback className="bg-blue-100 text-blue-700 font-bold">
|
||||||
|
{s.employee_name?.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-sm truncate">{s.employee_name}</p>
|
||||||
|
<p className="text-xs text-slate-500">{s.position}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Star className="w-3 h-3 mr-1 text-amber-500" />
|
||||||
|
{s.rating || 4.5}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{s.reliability_score || 95}% reliable
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
{localStaff.length === 0 && (
|
||||||
|
<p className="text-center text-slate-400 mt-8">All staff assigned</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Events Schedule */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
{localEvents.map(event => (
|
||||||
|
<Card key={event.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{event.event_name}</CardTitle>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-sm text-slate-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
{format(new Date(event.date), 'MMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
{event.hub || event.event_location}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={
|
||||||
|
(event.assigned_staff?.length || 0) >= (event.requested || 0)
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: "bg-amber-100 text-amber-700"
|
||||||
|
}>
|
||||||
|
{event.assigned_staff?.length || 0}/{event.requested || 0} filled
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Droppable droppableId={`event-${event.id}`}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
className={`min-h-[120px] rounded-lg p-3 transition-colors ${
|
||||||
|
snapshot.isDraggingOver ? 'bg-green-50 border-2 border-green-300' : 'bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{event.assigned_staff?.map((s, index) => (
|
||||||
|
<Draggable key={s.staff_id} draggableId={s.staff_id} index={index}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
className={`bg-white rounded-lg p-2 border border-slate-200 ${
|
||||||
|
snapshot.isDragging ? 'shadow-lg ring-2 ring-green-400' : ''
|
||||||
|
} cursor-move`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="w-8 h-8">
|
||||||
|
<AvatarFallback className="bg-green-100 text-green-700 text-xs">
|
||||||
|
{s.staff_name?.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-xs truncate">{s.staff_name}</p>
|
||||||
|
<p className="text-xs text-slate-500">{s.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{provided.placeholder}
|
||||||
|
{(!event.assigned_staff || event.assigned_staff.length === 0) && (
|
||||||
|
<p className="text-center text-slate-400 text-sm py-8">
|
||||||
|
Drag staff here to assign
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DragDropContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
frontend-web/src/components/scheduling/SmartAssignmentEngine.jsx
Normal file
274
frontend-web/src/components/scheduling/SmartAssignmentEngine.jsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart Assignment Engine - Core Logic
|
||||||
|
* Removes 85% of manual work with intelligent assignment algorithms
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Calculate worker fatigue based on recent shifts
|
||||||
|
export const calculateFatigue = (staff, allEvents) => {
|
||||||
|
const now = new Date();
|
||||||
|
const last7Days = allEvents.filter(e => {
|
||||||
|
const eventDate = new Date(e.date);
|
||||||
|
const diffDays = (now - eventDate) / (1000 * 60 * 60 * 24);
|
||||||
|
return diffDays >= 0 && diffDays <= 7 &&
|
||||||
|
e.assigned_staff?.some(s => s.staff_id === staff.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const shiftsLast7Days = last7Days.length;
|
||||||
|
// Fatigue score: 0 (fresh) to 100 (exhausted)
|
||||||
|
return Math.min(shiftsLast7Days * 15, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate proximity score (0-100, higher is closer)
|
||||||
|
export const calculateProximity = (staff, eventLocation) => {
|
||||||
|
if (!staff.hub_location || !eventLocation) return 50;
|
||||||
|
|
||||||
|
// Simple match-based proximity (in production, use geocoding)
|
||||||
|
if (staff.hub_location.toLowerCase() === eventLocation.toLowerCase()) return 100;
|
||||||
|
if (staff.hub_location.toLowerCase().includes(eventLocation.toLowerCase()) ||
|
||||||
|
eventLocation.toLowerCase().includes(staff.hub_location.toLowerCase())) return 75;
|
||||||
|
return 30;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate compliance score
|
||||||
|
export const calculateCompliance = (staff) => {
|
||||||
|
const hasBackground = staff.background_check_status === 'cleared';
|
||||||
|
const hasCertifications = staff.certifications?.length > 0;
|
||||||
|
const isActive = staff.employment_type && staff.employment_type !== 'Medical Leave';
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
if (hasBackground) score += 40;
|
||||||
|
if (hasCertifications) score += 30;
|
||||||
|
if (isActive) score += 30;
|
||||||
|
|
||||||
|
return score;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate cost optimization score
|
||||||
|
export const calculateCostScore = (staff, role, vendorRates) => {
|
||||||
|
// Find matching rate for this staff/role
|
||||||
|
const rate = vendorRates.find(r =>
|
||||||
|
r.vendor_id === staff.vendor_id &&
|
||||||
|
r.role_name === role
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rate) return 50;
|
||||||
|
|
||||||
|
// Lower cost = higher score (inverted)
|
||||||
|
const avgMarket = rate.market_average || rate.client_rate;
|
||||||
|
if (!avgMarket) return 50;
|
||||||
|
|
||||||
|
const costRatio = rate.client_rate / avgMarket;
|
||||||
|
return Math.max(0, Math.min(100, (1 - costRatio) * 100 + 50));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect shift time overlaps
|
||||||
|
export const hasTimeOverlap = (shift1, shift2, bufferMinutes = 30) => {
|
||||||
|
if (!shift1.start_time || !shift1.end_time || !shift2.start_time || !shift2.end_time) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseTime = (timeStr) => {
|
||||||
|
const [time, period] = timeStr.split(' ');
|
||||||
|
let [hours, minutes] = time.split(':').map(Number);
|
||||||
|
if (period === 'PM' && hours !== 12) hours += 12;
|
||||||
|
if (period === 'AM' && hours === 12) hours = 0;
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const s1Start = parseTime(shift1.start_time);
|
||||||
|
const s1End = parseTime(shift1.end_time);
|
||||||
|
const s2Start = parseTime(shift2.start_time);
|
||||||
|
const s2End = parseTime(shift2.end_time);
|
||||||
|
|
||||||
|
return (s1Start < s2End + bufferMinutes) && (s2Start < s1End + bufferMinutes);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for double bookings
|
||||||
|
export const checkDoubleBooking = (staff, event, allEvents) => {
|
||||||
|
const eventDate = new Date(event.date);
|
||||||
|
const eventShift = event.shifts?.[0];
|
||||||
|
|
||||||
|
if (!eventShift) return false;
|
||||||
|
|
||||||
|
const conflicts = allEvents.filter(e => {
|
||||||
|
if (e.id === event.id) return false;
|
||||||
|
|
||||||
|
const eDate = new Date(e.date);
|
||||||
|
const sameDay = eDate.toDateString() === eventDate.toDateString();
|
||||||
|
if (!sameDay) return false;
|
||||||
|
|
||||||
|
const isAssigned = e.assigned_staff?.some(s => s.staff_id === staff.id);
|
||||||
|
if (!isAssigned) return false;
|
||||||
|
|
||||||
|
// Check time overlap
|
||||||
|
const eShift = e.shifts?.[0];
|
||||||
|
if (!eShift) return false;
|
||||||
|
|
||||||
|
return hasTimeOverlap(eventShift.roles?.[0], eShift.roles?.[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return conflicts.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Smart Assignment Algorithm
|
||||||
|
export const smartAssign = async (event, role, allStaff, allEvents, vendorRates, options = {}) => {
|
||||||
|
const {
|
||||||
|
prioritizeSkill = true,
|
||||||
|
prioritizeReliability = true,
|
||||||
|
prioritizeVendor = true,
|
||||||
|
prioritizeFatigue = true,
|
||||||
|
prioritizeCompliance = true,
|
||||||
|
prioritizeProximity = true,
|
||||||
|
prioritizeCost = false,
|
||||||
|
preferredVendorId = null,
|
||||||
|
clientPreferences = {},
|
||||||
|
sectorStandards = {},
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Filter eligible staff
|
||||||
|
const eligible = allStaff.filter(staff => {
|
||||||
|
// Skill match
|
||||||
|
const hasSkill = staff.position === role.role || staff.position_2 === role.role;
|
||||||
|
if (!hasSkill) return false;
|
||||||
|
|
||||||
|
// Active status
|
||||||
|
if (staff.employment_type === 'Medical Leave') return false;
|
||||||
|
|
||||||
|
// Double booking check
|
||||||
|
if (checkDoubleBooking(staff, event, allEvents)) return false;
|
||||||
|
|
||||||
|
// Sector standards (if any)
|
||||||
|
if (sectorStandards.minimumRating && (staff.rating || 0) < sectorStandards.minimumRating) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Score each eligible staff member
|
||||||
|
const scored = eligible.map(staff => {
|
||||||
|
let totalScore = 0;
|
||||||
|
let weights = 0;
|
||||||
|
|
||||||
|
// Skill match (base score)
|
||||||
|
const isPrimarySkill = staff.position === role.role;
|
||||||
|
const skillScore = isPrimarySkill ? 100 : 75;
|
||||||
|
if (prioritizeSkill) {
|
||||||
|
totalScore += skillScore * 2;
|
||||||
|
weights += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reliability
|
||||||
|
if (prioritizeReliability) {
|
||||||
|
const reliabilityScore = staff.reliability_score || staff.shift_coverage_percentage || 85;
|
||||||
|
totalScore += reliabilityScore * 1.5;
|
||||||
|
weights += 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vendor priority
|
||||||
|
if (prioritizeVendor && preferredVendorId) {
|
||||||
|
const vendorMatch = staff.vendor_id === preferredVendorId ? 100 : 50;
|
||||||
|
totalScore += vendorMatch * 1.5;
|
||||||
|
weights += 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatigue (lower is better)
|
||||||
|
if (prioritizeFatigue) {
|
||||||
|
const fatigueScore = 100 - calculateFatigue(staff, allEvents);
|
||||||
|
totalScore += fatigueScore * 1;
|
||||||
|
weights += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compliance
|
||||||
|
if (prioritizeCompliance) {
|
||||||
|
const complianceScore = calculateCompliance(staff);
|
||||||
|
totalScore += complianceScore * 1.2;
|
||||||
|
weights += 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proximity
|
||||||
|
if (prioritizeProximity) {
|
||||||
|
const proximityScore = calculateProximity(staff, event.event_location || event.hub);
|
||||||
|
totalScore += proximityScore * 1;
|
||||||
|
weights += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cost optimization
|
||||||
|
if (prioritizeCost) {
|
||||||
|
const costScore = calculateCostScore(staff, role.role, vendorRates);
|
||||||
|
totalScore += costScore * 1;
|
||||||
|
weights += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client preferences
|
||||||
|
if (clientPreferences.favoriteStaff?.includes(staff.id)) {
|
||||||
|
totalScore += 100 * 1.5;
|
||||||
|
weights += 1.5;
|
||||||
|
}
|
||||||
|
if (clientPreferences.blockedStaff?.includes(staff.id)) {
|
||||||
|
totalScore = 0; // Exclude completely
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalScore = weights > 0 ? totalScore / weights : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
staff,
|
||||||
|
score: finalScore,
|
||||||
|
breakdown: {
|
||||||
|
skill: skillScore,
|
||||||
|
reliability: staff.reliability_score || 85,
|
||||||
|
fatigue: 100 - calculateFatigue(staff, allEvents),
|
||||||
|
compliance: calculateCompliance(staff),
|
||||||
|
proximity: calculateProximity(staff, event.event_location || event.hub),
|
||||||
|
cost: calculateCostScore(staff, role.role, vendorRates),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by score descending
|
||||||
|
scored.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
return scored;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-fill open shifts
|
||||||
|
export const autoFillShifts = async (event, allStaff, allEvents, vendorRates, options) => {
|
||||||
|
const shifts = event.shifts || [];
|
||||||
|
const assignments = [];
|
||||||
|
|
||||||
|
for (const shift of shifts) {
|
||||||
|
for (const role of shift.roles || []) {
|
||||||
|
const needed = (role.count || 0) - (role.assigned || 0);
|
||||||
|
if (needed <= 0) continue;
|
||||||
|
|
||||||
|
const scored = await smartAssign(event, role, allStaff, allEvents, vendorRates, options);
|
||||||
|
const selected = scored.slice(0, needed);
|
||||||
|
|
||||||
|
assignments.push(...selected.map(s => ({
|
||||||
|
staff_id: s.staff.id,
|
||||||
|
staff_name: s.staff.employee_name,
|
||||||
|
email: s.staff.email,
|
||||||
|
role: role.role,
|
||||||
|
department: role.department,
|
||||||
|
shift_name: shift.shift_name,
|
||||||
|
score: s.score,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignments;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
smartAssign,
|
||||||
|
autoFillShifts,
|
||||||
|
calculateFatigue,
|
||||||
|
calculateProximity,
|
||||||
|
calculateCompliance,
|
||||||
|
calculateCostScore,
|
||||||
|
hasTimeOverlap,
|
||||||
|
checkDoubleBooking,
|
||||||
|
};
|
||||||
137
frontend-web/src/components/scheduling/WorkerInfoCard.jsx
Normal file
137
frontend-web/src/components/scheduling/WorkerInfoCard.jsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
HoverCard,
|
||||||
|
HoverCardContent,
|
||||||
|
HoverCardTrigger,
|
||||||
|
} from "@/components/ui/hover-card";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Star, MapPin, Clock, Award, TrendingUp, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worker Info Hover Card
|
||||||
|
* Shows comprehensive staff info: role, ratings, history, reliability
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default function WorkerInfoCard({ staff, trigger }) {
|
||||||
|
if (!staff) return trigger;
|
||||||
|
|
||||||
|
const reliabilityColor = (score) => {
|
||||||
|
if (score >= 95) return "text-green-600";
|
||||||
|
if (score >= 85) return "text-amber-600";
|
||||||
|
return "text-red-600";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoverCard>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
{trigger}
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent className="w-80">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Avatar className="w-14 h-14">
|
||||||
|
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-blue-700 text-white font-bold text-lg">
|
||||||
|
{staff.employee_name?.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-bold text-slate-900">{staff.employee_name}</h4>
|
||||||
|
<p className="text-sm text-slate-600">{staff.position}</p>
|
||||||
|
{staff.position_2 && (
|
||||||
|
<p className="text-xs text-slate-500">Also: {staff.position_2}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating & Reliability */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex items-center gap-2 bg-amber-50 rounded-lg p-2">
|
||||||
|
<Star className="w-4 h-4 text-amber-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Rating</p>
|
||||||
|
<p className="font-bold text-amber-700">{staff.rating || 4.5} ★</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-green-50 rounded-lg p-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-green-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Reliability</p>
|
||||||
|
<p className={`font-bold ${reliabilityColor(staff.reliability_score || 90)}`}>
|
||||||
|
{staff.reliability_score || 90}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Experience & History */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-slate-600">
|
||||||
|
{staff.total_shifts || 0} shifts completed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<MapPin className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-slate-600">{staff.hub_location || staff.city || "Unknown location"}</span>
|
||||||
|
</div>
|
||||||
|
{staff.certifications && staff.certifications.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Award className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-slate-600">
|
||||||
|
{staff.certifications.length} certification{staff.certifications.length > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certifications */}
|
||||||
|
{staff.certifications && staff.certifications.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-semibold text-slate-700 uppercase">Certifications</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{staff.certifications.slice(0, 3).map((cert, idx) => (
|
||||||
|
<Badge key={idx} variant="outline" className="text-xs">
|
||||||
|
{cert.name || cert.cert_name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Performance Indicators */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 pt-2 border-t">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-slate-500">On-Time</p>
|
||||||
|
<p className="font-bold text-sm text-green-600">
|
||||||
|
{staff.shift_coverage_percentage || 95}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-slate-500">No-Shows</p>
|
||||||
|
<p className="font-bold text-sm text-slate-700">
|
||||||
|
{staff.no_show_count || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-slate-500">Cancels</p>
|
||||||
|
<p className="font-bold text-sm text-slate-700">
|
||||||
|
{staff.cancellation_count || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
|
{staff.background_check_status !== 'cleared' && (
|
||||||
|
<div className="flex items-center gap-2 bg-red-50 rounded-lg p-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||||
|
<p className="text-xs text-red-600">Background check pending</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
frontend-web/src/components/tasks/TaskCard.jsx
Normal file
106
frontend-web/src/components/tasks/TaskCard.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { MoreVertical, Paperclip, MessageSquare, Calendar } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
const priorityConfig = {
|
||||||
|
high: { bg: "bg-blue-100", text: "text-blue-700", label: "High" },
|
||||||
|
normal: { bg: "bg-teal-100", text: "text-teal-700", label: "Normal" },
|
||||||
|
low: { bg: "bg-orange-100", text: "text-orange-700", label: "Low" }
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressColor = (progress) => {
|
||||||
|
if (progress >= 75) return "bg-teal-500";
|
||||||
|
if (progress >= 50) return "bg-blue-500";
|
||||||
|
if (progress >= 25) return "bg-amber-500";
|
||||||
|
return "bg-slate-400";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TaskCard({ task, provided, onClick }) {
|
||||||
|
const priority = priorityConfig[task.priority] || priorityConfig.normal;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={provided?.innerRef}
|
||||||
|
{...provided?.draggableProps}
|
||||||
|
{...provided?.dragHandleProps}
|
||||||
|
onClick={onClick}
|
||||||
|
className="bg-white border border-slate-200 hover:shadow-md transition-all cursor-pointer mb-3"
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<h4 className="font-semibold text-slate-900 text-sm flex-1">{task.task_name}</h4>
|
||||||
|
<button className="text-slate-400 hover:text-slate-600">
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority & Date */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<Badge className={`${priority.bg} ${priority.text} text-xs font-semibold`}>
|
||||||
|
{priority.label}
|
||||||
|
</Badge>
|
||||||
|
{task.due_date && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-slate-500">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{format(new Date(task.due_date), 'd MMM')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${progressColor(task.progress || 0)} transition-all`}
|
||||||
|
style={{ width: `${task.progress || 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-slate-600 ml-3">{task.progress || 0}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{/* Assigned Members */}
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{(task.assigned_members || []).slice(0, 3).map((member, idx) => (
|
||||||
|
<Avatar key={idx} className="w-7 h-7 border-2 border-white">
|
||||||
|
<img
|
||||||
|
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
|
||||||
|
alt={member.member_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
|
{(task.assigned_members?.length || 0) > 3 && (
|
||||||
|
<div className="w-7 h-7 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
|
||||||
|
+{task.assigned_members.length - 3}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-3 text-slate-500">
|
||||||
|
{(task.attachment_count || 0) > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
<Paperclip className="w-3.5 h-3.5" />
|
||||||
|
<span>{task.attachment_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(task.comment_count || 0) > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
<MessageSquare className="w-3.5 h-3.5" />
|
||||||
|
<span>{task.comment_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend-web/src/components/tasks/TaskColumn.jsx
Normal file
56
frontend-web/src/components/tasks/TaskColumn.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Plus, MoreVertical } from "lucide-react";
|
||||||
|
import { Droppable } from "@hello-pangea/dnd";
|
||||||
|
|
||||||
|
const columnConfig = {
|
||||||
|
pending: { bg: "bg-blue-500", label: "Pending" },
|
||||||
|
in_progress: { bg: "bg-amber-500", label: "In Progress" },
|
||||||
|
on_hold: { bg: "bg-teal-500", label: "On Hold" },
|
||||||
|
completed: { bg: "bg-green-500", label: "Completed" }
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TaskColumn({ status, tasks, children, onAddTask }) {
|
||||||
|
const config = columnConfig[status] || columnConfig.pending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-w-[320px]">
|
||||||
|
{/* Column Header */}
|
||||||
|
<div className={`${config.bg} text-white rounded-lg px-4 py-3 mb-4 flex items-center justify-between`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-bold">{config.label}</span>
|
||||||
|
<Badge className="bg-white/20 text-white border-0 font-bold">
|
||||||
|
{tasks.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onAddTask(status)}
|
||||||
|
className="w-6 h-6 hover:bg-white/20 rounded flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button className="w-6 h-6 hover:bg-white/20 rounded flex items-center justify-center transition-colors">
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Droppable Area */}
|
||||||
|
<Droppable droppableId={status}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
className={`min-h-[500px] rounded-lg p-3 transition-colors ${
|
||||||
|
snapshot.isDraggingOver ? 'bg-blue-50 border-2 border-dashed border-blue-300' : 'bg-slate-50/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
526
frontend-web/src/components/tasks/TaskDetailModal.jsx
Normal file
526
frontend-web/src/components/tasks/TaskDetailModal.jsx
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Calendar, Paperclip, Send, Upload, FileText, Download, AtSign, Smile, Plus, Home, Activity, Mail, Clock, Zap, PauseCircle, CheckCircle } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
const priorityConfig = {
|
||||||
|
high: { bg: "bg-blue-100", text: "text-blue-700", label: "High" },
|
||||||
|
normal: { bg: "bg-teal-100", text: "text-teal-700", label: "Normal" },
|
||||||
|
low: { bg: "bg-amber-100", text: "text-amber-700", label: "Low" }
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TaskDetailModal({ task, open, onClose }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [status, setStatus] = useState(task?.status || "pending");
|
||||||
|
const [activeTab, setActiveTab] = useState("updates");
|
||||||
|
const [emailNotification, setEmailNotification] = useState(false);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
// Auto-calculate progress based on activity
|
||||||
|
const calculateProgress = () => {
|
||||||
|
if (!task) return 0;
|
||||||
|
|
||||||
|
let progressScore = 0;
|
||||||
|
|
||||||
|
// Status contributes to progress
|
||||||
|
if (task.status === "completed") return 100;
|
||||||
|
if (task.status === "in_progress") progressScore += 40;
|
||||||
|
if (task.status === "on_hold") progressScore += 20;
|
||||||
|
|
||||||
|
// Comments/updates show activity
|
||||||
|
if (task.comment_count > 0) progressScore += Math.min(task.comment_count * 5, 20);
|
||||||
|
|
||||||
|
// Files attached show work done
|
||||||
|
if (task.attachment_count > 0) progressScore += Math.min(task.attachment_count * 10, 20);
|
||||||
|
|
||||||
|
// Assigned members
|
||||||
|
if (task.assigned_members?.length > 0) progressScore += 20;
|
||||||
|
|
||||||
|
return Math.min(progressScore, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentProgress = calculateProgress();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (task && currentProgress !== task.progress) {
|
||||||
|
updateTaskMutation.mutate({
|
||||||
|
id: task.id,
|
||||||
|
data: { ...task, progress: currentProgress }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [currentProgress]);
|
||||||
|
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ['current-user-task-modal'],
|
||||||
|
queryFn: () => base44.auth.me(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: comments = [] } = useQuery({
|
||||||
|
queryKey: ['task-comments', task?.id],
|
||||||
|
queryFn: () => base44.entities.TaskComment.filter({ task_id: task?.id }),
|
||||||
|
enabled: !!task?.id,
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const addCommentMutation = useMutation({
|
||||||
|
mutationFn: (commentData) => base44.entities.TaskComment.create(commentData),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['task-comments', task?.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
setComment("");
|
||||||
|
toast({
|
||||||
|
title: "✅ Comment Added",
|
||||||
|
description: "Your comment has been posted",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTaskMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }) => base44.entities.Task.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Task Updated",
|
||||||
|
description: "Changes saved successfully",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFileUpload = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||||
|
|
||||||
|
const newFile = {
|
||||||
|
file_name: file.name,
|
||||||
|
file_url: file_url,
|
||||||
|
file_size: file.size,
|
||||||
|
uploaded_by: user?.full_name || user?.email || "User",
|
||||||
|
uploaded_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedFiles = [...(task.files || []), newFile];
|
||||||
|
|
||||||
|
await updateTaskMutation.mutateAsync({
|
||||||
|
id: task.id,
|
||||||
|
data: {
|
||||||
|
...task,
|
||||||
|
files: updatedFiles,
|
||||||
|
attachment_count: updatedFiles.length,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add system comment
|
||||||
|
await addCommentMutation.mutateAsync({
|
||||||
|
task_id: task.id,
|
||||||
|
author_id: user?.id,
|
||||||
|
author_name: user?.full_name || user?.email || "User",
|
||||||
|
author_avatar: user?.profile_picture,
|
||||||
|
comment: `Uploaded file: ${file.name}`,
|
||||||
|
is_system: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "✅ File Uploaded",
|
||||||
|
description: `${file.name} added successfully`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "❌ Upload Failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async (newStatus) => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
await updateTaskMutation.mutateAsync({
|
||||||
|
id: task.id,
|
||||||
|
data: { ...task, status: newStatus }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add system comment
|
||||||
|
await addCommentMutation.mutateAsync({
|
||||||
|
task_id: task.id,
|
||||||
|
author_id: user?.id,
|
||||||
|
author_name: "System",
|
||||||
|
author_avatar: "",
|
||||||
|
comment: `Status changed to ${newStatus.replace('_', ' ')}`,
|
||||||
|
is_system: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddComment = async () => {
|
||||||
|
if (!comment.trim()) return;
|
||||||
|
|
||||||
|
await addCommentMutation.mutateAsync({
|
||||||
|
task_id: task.id,
|
||||||
|
author_id: user?.id,
|
||||||
|
author_name: user?.full_name || user?.email || "User",
|
||||||
|
author_avatar: user?.profile_picture,
|
||||||
|
comment: comment.trim(),
|
||||||
|
is_system: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update comment count
|
||||||
|
await updateTaskMutation.mutateAsync({
|
||||||
|
id: task.id,
|
||||||
|
data: {
|
||||||
|
...task,
|
||||||
|
comment_count: (task.comment_count || 0) + 1,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email notifications if enabled
|
||||||
|
if (emailNotification && task.assigned_members) {
|
||||||
|
for (const member of task.assigned_members) {
|
||||||
|
try {
|
||||||
|
await base44.integrations.Core.SendEmail({
|
||||||
|
to: member.member_email || `${member.member_name}@example.com`,
|
||||||
|
subject: `New update on task: ${task.task_name}`,
|
||||||
|
body: `${user?.full_name || "A team member"} posted an update:\n\n"${comment.trim()}"\n\nView task details in the app.`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send email:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast({
|
||||||
|
title: "✅ Update Sent",
|
||||||
|
description: "Email notifications sent to team members",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMention = () => {
|
||||||
|
const textarea = document.querySelector('textarea');
|
||||||
|
if (textarea) {
|
||||||
|
const cursorPos = textarea.selectionStart;
|
||||||
|
const textBefore = comment.substring(0, cursorPos);
|
||||||
|
const textAfter = comment.substring(cursorPos);
|
||||||
|
setComment(textBefore + '@' + textAfter);
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(cursorPos + 1, cursorPos + 1);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmoji = () => {
|
||||||
|
const emojis = ['👍', '❤️', '😊', '🎉', '✅', '🔥', '💪', '🚀'];
|
||||||
|
const randomEmoji = emojis[Math.floor(Math.random() * emojis.length)];
|
||||||
|
setComment(comment + randomEmoji);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!task) return null;
|
||||||
|
|
||||||
|
const priority = priorityConfig[task.priority] || priorityConfig.normal;
|
||||||
|
const sortedComments = [...comments].sort((a, b) =>
|
||||||
|
new Date(a.created_date) - new Date(b.created_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
const getProgressColor = () => {
|
||||||
|
if (currentProgress === 100) return "bg-green-500";
|
||||||
|
if (currentProgress >= 70) return "bg-blue-500";
|
||||||
|
if (currentProgress >= 40) return "bg-amber-500";
|
||||||
|
return "bg-slate-400";
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: "pending", label: "Pending", icon: Clock, color: "bg-slate-100 text-slate-700 border-slate-300" },
|
||||||
|
{ value: "in_progress", label: "In Progress", icon: Zap, color: "bg-blue-100 text-blue-700 border-blue-300" },
|
||||||
|
{ value: "on_hold", label: "On Hold", icon: PauseCircle, color: "bg-orange-100 text-orange-700 border-orange-300" },
|
||||||
|
{ value: "completed", label: "Completed", icon: CheckCircle, color: "bg-green-100 text-green-700 border-green-300" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col p-0">
|
||||||
|
{/* Header with Task Info */}
|
||||||
|
<div className="p-6 pb-4 border-b">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 mb-2">{task.task_name}</h2>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<Badge className={`${priority.bg} ${priority.text} text-xs font-semibold px-3 py-1`}>
|
||||||
|
{priority.label} Priority
|
||||||
|
</Badge>
|
||||||
|
{task.due_date && (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-slate-600 bg-slate-100 px-3 py-1 rounded-full">
|
||||||
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
|
{format(new Date(task.due_date), 'MMM d, yyyy')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`h-2 w-24 bg-slate-200 rounded-full overflow-hidden`}>
|
||||||
|
<div className={`h-full ${getProgressColor()} transition-all duration-500`} style={{ width: `${currentProgress}%` }}></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-slate-700">{currentProgress}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{statusOptions.map((option) => {
|
||||||
|
const IconComponent = option.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => handleStatusChange(option.value)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 font-semibold text-sm transition-all ${
|
||||||
|
status === option.value
|
||||||
|
? `${option.color} shadow-md scale-105`
|
||||||
|
: "bg-white text-slate-400 border-slate-200 hover:border-slate-300 hover:text-slate-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<IconComponent className="w-4 h-4" />
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assigned Members */}
|
||||||
|
{task.assigned_members && task.assigned_members.length > 0 && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs font-semibold text-slate-500">ASSIGNED:</span>
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{task.assigned_members.slice(0, 5).map((member, idx) => (
|
||||||
|
<Avatar key={idx} className="w-8 h-8 border-2 border-white">
|
||||||
|
<img
|
||||||
|
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
|
||||||
|
alt={member.member_name}
|
||||||
|
title={member.member_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
|
{task.assigned_members.length > 5 && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-bold text-slate-600">
|
||||||
|
+{task.assigned_members.length - 5}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-6 h-12">
|
||||||
|
<TabsTrigger value="updates" className="gap-2 data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none">
|
||||||
|
<Home className="w-4 h-4" />
|
||||||
|
Updates
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="files" className="gap-2 data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none">
|
||||||
|
<Paperclip className="w-4 h-4" />
|
||||||
|
Files ({task.files?.length || 0})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="activity" className="gap-2 data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none">
|
||||||
|
<Activity className="w-4 h-4" />
|
||||||
|
Activity Log
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Updates Tab */}
|
||||||
|
<TabsContent value="updates" className="flex-1 overflow-y-auto m-0 p-6 space-y-4">
|
||||||
|
<div className="bg-white border-2 border-slate-200 rounded-xl overflow-hidden">
|
||||||
|
<div className="p-4 border-b bg-slate-50 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold text-slate-600">Write an update</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEmailNotification(!emailNotification)}
|
||||||
|
className={emailNotification ? "text-[#0A39DF] bg-blue-50" : "text-slate-500 hover:text-[#0A39DF]"}
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4 mr-2" />
|
||||||
|
{emailNotification ? "Email enabled ✓" : "Update via email"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="Write an update and mention others with @"
|
||||||
|
rows={4}
|
||||||
|
className="border-0 resize-none focus-visible:ring-0 text-base"
|
||||||
|
/>
|
||||||
|
<div className="p-3 bg-slate-50 flex items-center justify-between border-t">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleMention}
|
||||||
|
className="text-slate-500 hover:text-[#0A39DF]"
|
||||||
|
title="Mention someone"
|
||||||
|
>
|
||||||
|
<AtSign className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="text-slate-500 hover:text-[#0A39DF]"
|
||||||
|
title="Attach file"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleEmoji}
|
||||||
|
className="text-slate-500 hover:text-[#0A39DF]"
|
||||||
|
title="Add emoji"
|
||||||
|
>
|
||||||
|
<Smile className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<input ref={fileInputRef} type="file" onChange={handleFileUpload} className="hidden" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddComment}
|
||||||
|
disabled={!comment.trim() || addCommentMutation.isPending}
|
||||||
|
className="bg-[#0A39DF] hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
{addCommentMutation.isPending ? "Posting..." : "Post Update"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments Feed */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedComments.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-slate-400">
|
||||||
|
<Home className="w-16 h-16 mx-auto mb-3 opacity-20" />
|
||||||
|
<p className="text-sm">No updates yet. Be the first to post!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sortedComments.map((commentItem) => (
|
||||||
|
<div key={commentItem.id} className="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Avatar className="w-10 h-10 flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={commentItem.author_avatar || `https://i.pravatar.cc/150?u=${encodeURIComponent(commentItem.author_name)}`}
|
||||||
|
alt={commentItem.author_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-bold text-slate-900">{commentItem.author_name}</span>
|
||||||
|
{commentItem.is_system && (
|
||||||
|
<Badge variant="outline" className="text-xs">System</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-slate-400">•</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{format(new Date(commentItem.created_date), 'MMM d, h:mm a')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-700 leading-relaxed">{commentItem.comment}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Files Tab */}
|
||||||
|
<TabsContent value="files" className="flex-1 overflow-y-auto m-0 p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="bg-[#0A39DF] hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
{uploading ? "Uploading..." : "Upload File"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.files && task.files.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{task.files.map((file, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between p-4 bg-white border-2 border-slate-200 rounded-xl hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||||
|
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<FileText className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-slate-900 truncate">{file.file_name}</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500 mt-1">
|
||||||
|
<span>{(file.file_size / 1024).toFixed(1)} KB</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{file.uploaded_by}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{format(new Date(file.uploaded_at), 'MMM d, h:mm a')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href={file.file_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Button size="sm" variant="outline" className="border-blue-200 text-blue-600 hover:bg-blue-50">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16 border-2 border-dashed border-slate-200 rounded-xl">
|
||||||
|
<Paperclip className="w-16 h-16 mx-auto mb-3 text-slate-300" />
|
||||||
|
<p className="text-slate-500 font-medium mb-2">No files attached yet</p>
|
||||||
|
<p className="text-sm text-slate-400">Upload files to share with your team</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Activity Log Tab */}
|
||||||
|
<TabsContent value="activity" className="flex-1 overflow-y-auto m-0 p-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedComments.filter(c => c.is_system).length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-slate-400">
|
||||||
|
<Activity className="w-16 h-16 mx-auto mb-3 opacity-20" />
|
||||||
|
<p className="text-sm">No activity logged yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-slate-200"></div>
|
||||||
|
{sortedComments.filter(c => c.is_system).map((activity) => (
|
||||||
|
<div key={activity.id} className="relative pl-10 pb-6">
|
||||||
|
<div className="absolute left-2.5 w-3 h-3 bg-[#0A39DF] rounded-full border-2 border-white"></div>
|
||||||
|
<div className="bg-white border border-slate-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-slate-700">{activity.comment}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
{format(new Date(activity.created_date), 'MMM d, yyyy • h:mm a')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,18 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Slider = React.forwardRef(({ className, ...props }, ref) => (
|
const Slider = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<SliderPrimitive.Root
|
<SliderPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
className={`relative flex w-full touch-none select-none items-center ${className}`}
|
||||||
{...props}>
|
{...props}
|
||||||
<SliderPrimitive.Track
|
>
|
||||||
className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-slate-100">
|
||||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
<SliderPrimitive.Range className="absolute h-full bg-[#0A39DF]" />
|
||||||
</SliderPrimitive.Track>
|
</SliderPrimitive.Track>
|
||||||
<SliderPrimitive.Thumb
|
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-[#0A39DF] bg-white ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0A39DF] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||||
className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
|
||||||
</SliderPrimitive.Root>
|
</SliderPrimitive.Root>
|
||||||
))
|
))
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName
|
Slider.displayName = SliderPrimitive.Root.displayName
|
||||||
|
|
||||||
export { Slider }
|
export { Slider }
|
||||||
@@ -1,41 +1,33 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Tabs = TabsPrimitive.Root
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={`inline-flex h-10 items-center justify-center rounded-md bg-slate-100 p-1 text-slate-500 ${className}`}
|
||||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
{...props}
|
||||||
className
|
/>
|
||||||
)}
|
|
||||||
{...props} />
|
|
||||||
))
|
))
|
||||||
TabsList.displayName = TabsPrimitive.List.displayName
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={`inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-slate-950 data-[state=active]:shadow-sm ${className}`}
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
{...props}
|
||||||
className
|
/>
|
||||||
)}
|
|
||||||
{...props} />
|
|
||||||
))
|
))
|
||||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={`mt-2 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 ${className}`}
|
||||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
{...props}
|
||||||
className
|
/>
|
||||||
)}
|
|
||||||
{...props} />
|
|
||||||
))
|
))
|
||||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
358
frontend-web/src/components/vendor/PreferredVendorPanel.jsx
vendored
Normal file
358
frontend-web/src/components/vendor/PreferredVendorPanel.jsx
vendored
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Award, Star, MapPin, Users, TrendingUp, CheckCircle2, Edit2, X, Sparkles, Shield } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { createPageUrl } from "@/utils";
|
||||||
|
|
||||||
|
export default function PreferredVendorPanel({ user, compact = false }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [showChangeDialog, setShowChangeDialog] = useState(false);
|
||||||
|
|
||||||
|
// Fetch preferred vendor details
|
||||||
|
const { data: preferredVendor, isLoading } = useQuery({
|
||||||
|
queryKey: ['preferred-vendor', user?.preferred_vendor_id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!user?.preferred_vendor_id) return null;
|
||||||
|
const vendors = await base44.entities.Vendor.list();
|
||||||
|
return vendors.find(v => v.id === user.preferred_vendor_id);
|
||||||
|
},
|
||||||
|
enabled: !!user?.preferred_vendor_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all approved vendors for comparison
|
||||||
|
const { data: allVendors } = useQuery({
|
||||||
|
queryKey: ['all-vendors'],
|
||||||
|
queryFn: () => base44.entities.Vendor.filter({
|
||||||
|
approval_status: 'approved',
|
||||||
|
is_active: true
|
||||||
|
}),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove preferred vendor mutation
|
||||||
|
const removePreferredMutation = useMutation({
|
||||||
|
mutationFn: () => base44.auth.updateMe({
|
||||||
|
preferred_vendor_id: null,
|
||||||
|
preferred_vendor_name: null
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Preferred Vendor Removed",
|
||||||
|
description: "You can now select a new preferred vendor",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set preferred vendor mutation
|
||||||
|
const setPreferredMutation = useMutation({
|
||||||
|
mutationFn: (vendor) => base44.auth.updateMe({
|
||||||
|
preferred_vendor_id: vendor.id,
|
||||||
|
preferred_vendor_name: vendor.legal_name || vendor.doing_business_as
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['preferred-vendor'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Preferred Vendor Set",
|
||||||
|
description: "All new orders will route to this vendor by default",
|
||||||
|
});
|
||||||
|
setShowChangeDialog(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="animate-pulse flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-slate-200 rounded-lg" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-4 bg-slate-200 rounded w-1/2 mb-2" />
|
||||||
|
<div className="h-3 bg-slate-200 rounded w-1/3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preferredVendor && !compact) {
|
||||||
|
return (
|
||||||
|
<Card className="border-2 border-dashed border-blue-300 bg-blue-50/50">
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-blue-100 rounded-2xl flex items-center justify-center">
|
||||||
|
<Star className="w-8 h-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-lg text-slate-900 mb-2">
|
||||||
|
No Preferred Vendor Selected
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-600 mb-4">
|
||||||
|
Pick your go-to vendor for faster ordering and consistent service
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(createPageUrl("VendorMarketplace"))}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Star className="w-4 h-4 mr-2" />
|
||||||
|
Choose Preferred Vendor
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preferredVendor) return null;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-lg">
|
||||||
|
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<Award className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<p className="text-xs font-bold text-blue-600 uppercase tracking-wider">Preferred Vendor</p>
|
||||||
|
<Badge className="bg-blue-600 text-white text-xs px-2 py-0 border-0">PRIMARY</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="font-bold text-sm text-slate-900 truncate">
|
||||||
|
{preferredVendor.doing_business_as || preferredVendor.legal_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowChangeDialog(true)}
|
||||||
|
className="text-blue-600 hover:text-blue-700 hover:bg-blue-100"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="border-2 border-blue-300 bg-gradient-to-br from-blue-50 via-white to-indigo-50 shadow-lg">
|
||||||
|
<CardHeader className="border-b border-blue-200 pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-xl flex items-center justify-center">
|
||||||
|
<Award className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-bold text-slate-900">Your Preferred Vendor</CardTitle>
|
||||||
|
<p className="text-xs text-slate-600 mt-0.5">All orders route here by default</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-blue-600 text-white font-bold px-3 py-1.5 shadow-md">
|
||||||
|
PRIMARY
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Vendor Info */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-slate-100 to-slate-200 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||||
|
<Users className="w-8 h-8 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-bold text-xl text-slate-900 mb-1">
|
||||||
|
{preferredVendor.doing_business_as || preferredVendor.legal_name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-700 text-xs">
|
||||||
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
|
Approved
|
||||||
|
</Badge>
|
||||||
|
{preferredVendor.region && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<MapPin className="w-3 h-3 mr-1" />
|
||||||
|
{preferredVendor.region}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{preferredVendor.service_specialty && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{preferredVendor.service_specialty}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 pt-4 border-t border-slate-200">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-blue-600">
|
||||||
|
{preferredVendor.workforce_count || 0}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Staff Available</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Star className="w-5 h-5 text-amber-500 fill-amber-500" />
|
||||||
|
<p className="text-2xl font-bold text-slate-900">4.9</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Rating</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-emerald-600">98%</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Fill Rate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setShowChangeDialog(true)}
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4 mr-2" />
|
||||||
|
Switch Vendor
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => navigate(createPageUrl("VendorMarketplace"))}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
View Market
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => removePreferredMutation.mutate()}
|
||||||
|
disabled={removePreferredMutation.isPending}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Benefits Badge */}
|
||||||
|
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border-2 border-green-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Shield className="w-4 h-4 text-green-600 flex-shrink-0" />
|
||||||
|
<p className="text-green-800 font-medium">
|
||||||
|
<strong>Priority Support:</strong> Faster response times and dedicated account management
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Change Vendor Dialog */}
|
||||||
|
<Dialog open={showChangeDialog} onOpenChange={setShowChangeDialog}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Star className="w-6 h-6 text-blue-600" />
|
||||||
|
Select New Preferred Vendor
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose a vendor to route all your future orders to by default
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 mt-4">
|
||||||
|
{allVendors.map((vendor) => {
|
||||||
|
const isCurrentPreferred = vendor.id === preferredVendor?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={vendor.id}
|
||||||
|
className={`p-4 rounded-lg border-2 transition-all cursor-pointer ${
|
||||||
|
isCurrentPreferred
|
||||||
|
? 'border-blue-400 bg-blue-50'
|
||||||
|
: 'border-slate-200 hover:border-blue-300 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => !isCurrentPreferred && setPreferredMutation.mutate(vendor)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h3 className="font-bold text-lg text-slate-900">
|
||||||
|
{vendor.doing_business_as || vendor.legal_name}
|
||||||
|
</h3>
|
||||||
|
{isCurrentPreferred && (
|
||||||
|
<Badge className="bg-blue-600 text-white">
|
||||||
|
Current Preferred
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||||
|
{vendor.region && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<MapPin className="w-3 h-3 mr-1" />
|
||||||
|
{vendor.region}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{vendor.service_specialty && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{vendor.service_specialty}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{vendor.workforce_count || 0} staff
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
||||||
|
4.9
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-4 h-4 text-green-600" />
|
||||||
|
98% fill rate
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isCurrentPreferred && (
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setPreferredMutation.mutate(vendor);
|
||||||
|
}}
|
||||||
|
disabled={setPreferredMutation.isPending}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{allVendors.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-slate-400">
|
||||||
|
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="font-medium">No vendors available</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,282 +1,508 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
|
import React, { useState, useMemo } from "react";
|
||||||
import { base44 } from "@/api/base44Client";
|
import { base44 } from "@/api/base44Client";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Calendar as CalendarIcon, MapPin, Users, Clock, DollarSign, FileText, Plus, RefreshCw, Zap } from "lucide-react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { createPageUrl } from "@/utils";
|
import { createPageUrl } from "@/utils";
|
||||||
import { format, addDays } from "date-fns";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Tabs, // New import
|
||||||
|
TabsList, // New import
|
||||||
|
TabsTrigger, // New import
|
||||||
|
} from "@/components/ui/tabs"; // New import
|
||||||
|
import {
|
||||||
|
Search, Calendar, MapPin, Users, Eye, Edit, X, Trash2, FileText, // Edit instead of Edit2
|
||||||
|
Clock, DollarSign, Package, CheckCircle, AlertTriangle, Grid, List, Zap, Plus
|
||||||
|
} from "lucide-react";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import QuickReorderModal from "@/components/events/QuickReorderModal";
|
import { format, parseISO, isValid } from "date-fns";
|
||||||
|
|
||||||
|
const safeParseDate = (dateString) => {
|
||||||
|
if (!dateString) return null;
|
||||||
|
try {
|
||||||
|
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
|
||||||
|
return isValid(date) ? date : null;
|
||||||
|
} catch { return null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeFormatDate = (dateString, formatString) => {
|
||||||
|
const date = safeParseDate(dateString);
|
||||||
|
return date ? format(date, formatString) : '—';
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertTo12Hour = (time24) => {
|
||||||
|
if (!time24) return "-";
|
||||||
|
try {
|
||||||
|
const [hours, minutes] = time24.split(':');
|
||||||
|
const hour = parseInt(hours);
|
||||||
|
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||||
|
const hour12 = hour % 12 || 12;
|
||||||
|
return `${hour12}:${minutes} ${ampm}`;
|
||||||
|
} catch {
|
||||||
|
return time24;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (event) => {
|
||||||
|
if (event.is_rapid) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md">
|
||||||
|
<Zap className="w-3.5 h-3.5 fill-white" />
|
||||||
|
RAPID
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
'Draft': { bg: 'bg-slate-500', icon: FileText },
|
||||||
|
'Pending': { bg: 'bg-amber-500', icon: Clock },
|
||||||
|
'Partial Staffed': { bg: 'bg-orange-500', icon: AlertTriangle },
|
||||||
|
'Fully Staffed': { bg: 'bg-emerald-500', icon: CheckCircle },
|
||||||
|
'Active': { bg: 'bg-blue-500', icon: Users },
|
||||||
|
'Completed': { bg: 'bg-slate-400', icon: CheckCircle },
|
||||||
|
'Canceled': { bg: 'bg-red-500', icon: X },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[event.status] || { bg: 'bg-slate-400', icon: Clock };
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`inline-flex items-center gap-2 ${config.bg} text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md`}>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{event.status}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function ClientOrders() {
|
export default function ClientOrders() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const queryClient = useQueryClient();
|
||||||
const [reorderModalOpen, setReorderModalOpen] = useState(false);
|
|
||||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all"); // Updated values for Tabs
|
||||||
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false); // Changed from cancelDialog.open
|
||||||
|
const [orderToCancel, setOrderToCancel] = useState(null); // Changed from cancelDialog.order
|
||||||
|
|
||||||
const { data: user } = useQuery({
|
const { data: user } = useQuery({
|
||||||
queryKey: ['current-user'],
|
queryKey: ['current-user-client-orders'],
|
||||||
queryFn: () => base44.auth.me(),
|
queryFn: () => base44.auth.me(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: events } = useQuery({
|
const { data: allEvents = [] } = useQuery({
|
||||||
queryKey: ['client-events'],
|
queryKey: ['all-events-client'],
|
||||||
queryFn: () => base44.entities.Event.list('-date'),
|
queryFn: () => base44.entities.Event.list('-date'),
|
||||||
initialData: [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter events by current client
|
const clientEvents = useMemo(() => {
|
||||||
const clientEvents = events.filter(e =>
|
return allEvents.filter(e =>
|
||||||
e.client_email === user?.email || e.created_by === user?.email
|
e.client_email === user?.email ||
|
||||||
);
|
e.business_name === user?.company_name ||
|
||||||
|
e.created_by === user?.email
|
||||||
|
);
|
||||||
|
}, [allEvents, user]);
|
||||||
|
|
||||||
const filteredEvents = statusFilter === "all"
|
const cancelOrderMutation = useMutation({
|
||||||
? clientEvents
|
mutationFn: (orderId) => base44.entities.Event.update(orderId, { status: "Canceled" }),
|
||||||
: clientEvents.filter(e => {
|
onSuccess: () => {
|
||||||
if (statusFilter === "rapid_request") return e.is_rapid_request;
|
queryClient.invalidateQueries({ queryKey: ['all-events-client'] });
|
||||||
if (statusFilter === "pending") return e.status?.toLowerCase() === "pending" || e.status?.toLowerCase() === "draft";
|
toast({
|
||||||
return e.status?.toLowerCase() === statusFilter;
|
title: "✅ Order Canceled",
|
||||||
|
description: "Your order has been canceled successfully",
|
||||||
});
|
});
|
||||||
|
setCancelDialogOpen(false); // Updated
|
||||||
|
setOrderToCancel(null); // Updated
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: "❌ Failed to Cancel",
|
||||||
|
description: "Could not cancel order. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const getStatusColor = (status) => {
|
const filteredOrders = useMemo(() => { // Renamed from filteredEvents
|
||||||
const colors = {
|
let filtered = clientEvents;
|
||||||
'pending': 'bg-yellow-100 text-yellow-700',
|
|
||||||
'draft': 'bg-gray-100 text-gray-700',
|
if (searchTerm) {
|
||||||
'confirmed': 'bg-green-100 text-green-700',
|
const lower = searchTerm.toLowerCase();
|
||||||
'active': 'bg-blue-100 text-blue-700',
|
filtered = filtered.filter(e =>
|
||||||
'completed': 'bg-slate-100 text-slate-700',
|
e.event_name?.toLowerCase().includes(lower) ||
|
||||||
'canceled': 'bg-red-100 text-red-700',
|
e.business_name?.toLowerCase().includes(lower) ||
|
||||||
'cancelled': 'bg-red-100 text-red-700',
|
e.hub?.toLowerCase().includes(lower) ||
|
||||||
|
e.event_location?.toLowerCase().includes(lower) // Added event_location to search
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
// Reset time for comparison to only compare dates
|
||||||
|
now.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
filtered = filtered.filter(e => {
|
||||||
|
const eventDate = safeParseDate(e.date);
|
||||||
|
const isCompleted = e.status === "Completed";
|
||||||
|
const isCanceled = e.status === "Canceled";
|
||||||
|
const isFutureOrPresent = eventDate && eventDate >= now;
|
||||||
|
|
||||||
|
if (statusFilter === "active") {
|
||||||
|
return !isCompleted && !isCanceled && isFutureOrPresent;
|
||||||
|
} else if (statusFilter === "completed") {
|
||||||
|
return isCompleted;
|
||||||
|
}
|
||||||
|
return true; // For "all" or other statuses
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [clientEvents, searchTerm, statusFilter]);
|
||||||
|
|
||||||
|
const activeOrders = clientEvents.filter(e =>
|
||||||
|
e.status !== "Completed" && e.status !== "Canceled"
|
||||||
|
).length;
|
||||||
|
const completedOrders = clientEvents.filter(e => e.status === "Completed").length;
|
||||||
|
const totalSpent = clientEvents
|
||||||
|
.filter(e => e.status === "Completed")
|
||||||
|
.reduce((sum, e) => sum + (e.total || 0), 0);
|
||||||
|
|
||||||
|
const handleCancelOrder = (order) => {
|
||||||
|
setOrderToCancel(order); // Updated
|
||||||
|
setCancelDialogOpen(true); // Updated
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmCancel = () => {
|
||||||
|
if (orderToCancel) { // Updated
|
||||||
|
cancelOrderMutation.mutate(orderToCancel.id); // Updated
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canEditOrder = (order) => {
|
||||||
|
const eventDate = safeParseDate(order.date);
|
||||||
|
const now = new Date();
|
||||||
|
return order.status !== "Completed" &&
|
||||||
|
order.status !== "Canceled" &&
|
||||||
|
eventDate && eventDate > now; // Ensure eventDate is valid before comparison
|
||||||
|
};
|
||||||
|
|
||||||
|
const canCancelOrder = (order) => {
|
||||||
|
return order.status !== "Completed" && order.status !== "Canceled";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAssignmentStatus = (event) => {
|
||||||
|
const totalRequested = event.shifts?.reduce((accShift, shift) => {
|
||||||
|
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
|
||||||
|
}, 0) || 0;
|
||||||
|
|
||||||
|
const assigned = event.assigned_staff?.length || 0;
|
||||||
|
const percentage = totalRequested > 0 ? Math.round((assigned / totalRequested) * 100) : 0;
|
||||||
|
|
||||||
|
let badgeClass = 'bg-slate-100 text-slate-600'; // Default: no staff, or no roles requested
|
||||||
|
if (assigned > 0 && assigned < totalRequested) {
|
||||||
|
badgeClass = 'bg-orange-500 text-white'; // Partial Staffed
|
||||||
|
} else if (assigned >= totalRequested && totalRequested > 0) {
|
||||||
|
badgeClass = 'bg-emerald-500 text-white'; // Fully Staffed
|
||||||
|
} else if (assigned === 0 && totalRequested > 0) {
|
||||||
|
badgeClass = 'bg-red-500 text-white'; // Requested but 0 assigned
|
||||||
|
} else if (assigned > 0 && totalRequested === 0) {
|
||||||
|
badgeClass = 'bg-blue-500 text-white'; // Staff assigned but no roles explicitly requested (e.g., event set up, staff assigned, but roles not detailed or count is 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
badgeClass,
|
||||||
|
assigned,
|
||||||
|
requested: totalRequested,
|
||||||
|
percentage,
|
||||||
};
|
};
|
||||||
return colors[status?.toLowerCase()] || 'bg-slate-100 text-slate-700';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const stats = {
|
const getEventTimes = (event) => {
|
||||||
total: clientEvents.length,
|
const firstShift = event.shifts?.[0];
|
||||||
rapidRequest: clientEvents.filter(e => e.is_rapid_request).length,
|
const rolesInFirstShift = firstShift?.roles || [];
|
||||||
pending: clientEvents.filter(e => e.status === 'Pending' || e.status === 'Draft').length,
|
|
||||||
confirmed: clientEvents.filter(e => e.status === 'Confirmed').length,
|
|
||||||
completed: clientEvents.filter(e => e.status === 'Completed').length,
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQuickReorder = (event) => {
|
let startTime = null;
|
||||||
setSelectedEvent(event);
|
let endTime = null;
|
||||||
setReorderModalOpen(true);
|
|
||||||
|
if (rolesInFirstShift.length > 0) {
|
||||||
|
startTime = rolesInFirstShift[0].start_time || null;
|
||||||
|
endTime = rolesInFirstShift[0].end_time || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startTime: startTime ? convertTo12Hour(startTime) : "-",
|
||||||
|
endTime: endTime ? convertTo12Hour(endTime) : "-"
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-[1800px] mx-auto space-y-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className=""> {/* Removed mb-6 */}
|
||||||
<div>
|
<h1 className="text-2xl font-bold text-slate-900">My Orders</h1>
|
||||||
<h1 className="text-3xl font-bold text-[#1C323E]">My Orders</h1>
|
<p className="text-sm text-slate-500 mt-1">View and manage all your orders</p>
|
||||||
<p className="text-slate-500 mt-1">View and manage your event orders</p>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> {/* Removed mb-6 from here as it's now part of space-y-6 */}
|
||||||
|
<Card className="border border-blue-200 bg-blue-50">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||||
|
<Package className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-blue-600 font-semibold uppercase">TOTAL</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-700">{clientEvents.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border border-orange-200 bg-orange-50">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center">
|
||||||
|
<Clock className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-orange-600 font-semibold uppercase">ACTIVE</p>
|
||||||
|
<p className="text-2xl font-bold text-orange-700">{activeOrders}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border border-green-200 bg-green-50">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center">
|
||||||
|
<CheckCircle className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-green-600 font-semibold uppercase">COMPLETED</p>
|
||||||
|
<p className="text-2xl font-bold text-green-700">{completedOrders}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border border-purple-200 bg-purple-50">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-purple-500 rounded-lg flex items-center justify-center">
|
||||||
|
<DollarSign className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-purple-600 font-semibold uppercase">TOTAL SPENT</p>
|
||||||
|
<p className="text-2xl font-bold text-purple-700">${Math.round(totalSpent / 1000)}k</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl p-4 flex items-center gap-4 border shadow-sm">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" /> {/* Icon size updated */}
|
||||||
|
<Input
|
||||||
|
placeholder="Search orders..." // Placeholder text updated
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10 border-slate-300 h-10" // Class updated
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Tabs value={statusFilter} onValueChange={setStatusFilter} className="w-fit"> {/* Replaced Select with Tabs */}
|
||||||
onClick={() => navigate(createPageUrl("CreateEvent"))}
|
<TabsList>
|
||||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
<TabsTrigger value="all">All</TabsTrigger>
|
||||||
>
|
<TabsTrigger value="active">Active</TabsTrigger>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<TabsTrigger value="completed">Completed</TabsTrigger>
|
||||||
New Order
|
</TabsList>
|
||||||
</Button>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
<Card className="border-slate-200 shadow-sm"> {/* Card class updated */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-6 mb-8">
|
<CardContent className="p-0"> {/* CardContent padding updated */}
|
||||||
<Card className="border-slate-200">
|
<Table>
|
||||||
<CardContent className="p-6">
|
<TableHeader>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<TableRow className="bg-slate-50 hover:bg-slate-50"> {/* TableRow class updated */}
|
||||||
<FileText className="w-8 h-8 text-[#0A39DF]" />
|
<TableHead className="font-semibold text-slate-700">Order</TableHead> {/* Updated */}
|
||||||
</div>
|
<TableHead className="font-semibold text-slate-700">Date</TableHead> {/* Updated */}
|
||||||
<p className="text-sm text-slate-500">Total Orders</p>
|
<TableHead className="font-semibold text-slate-700">Location</TableHead> {/* Updated */}
|
||||||
<p className="text-3xl font-bold text-[#1C323E]">{stats.total}</p>
|
<TableHead className="font-semibold text-slate-700">Time</TableHead> {/* Updated */}
|
||||||
</CardContent>
|
<TableHead className="font-semibold text-slate-700">Status</TableHead> {/* Updated */}
|
||||||
</Card>
|
<TableHead className="font-semibold text-slate-700 text-center">Staff</TableHead> {/* Updated */}
|
||||||
|
<TableHead className="font-semibold text-slate-700 text-center">Invoice</TableHead> {/* Updated */}
|
||||||
|
<TableHead className="font-semibold text-slate-700 text-center">Actions</TableHead> {/* Updated */}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredOrders.length === 0 ? ( // Using filteredOrders
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-12 text-slate-500"> {/* Colspan updated */}
|
||||||
|
<Package className="w-12 h-12 mx-auto mb-3 text-slate-300" /> {/* Icon updated */}
|
||||||
|
<p className="font-medium">No orders found</p>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredOrders.map((order) => { // Using filteredOrders, renamed event to order
|
||||||
|
const assignment = getAssignmentStatus(order);
|
||||||
|
const { startTime, endTime } = getEventTimes(order);
|
||||||
|
const invoiceReady = order.status === "Completed";
|
||||||
|
// const eventDate = safeParseDate(order.date); // Not directly used here, safeFormatDate handles it.
|
||||||
|
|
||||||
<Card className="border-slate-200 bg-gradient-to-br from-red-50 to-white">
|
return (
|
||||||
<CardContent className="p-6">
|
<TableRow key={order.id} className="hover:bg-slate-50">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<TableCell> {/* Order cell */}
|
||||||
<Zap className="w-8 h-8 text-red-600" />
|
<div>
|
||||||
</div>
|
<p className="font-semibold text-slate-900">{order.event_name}</p>
|
||||||
<p className="text-sm text-slate-500">Rapid Requests</p>
|
<p className="text-xs text-slate-500">{order.business_name || "—"}</p>
|
||||||
<p className="text-3xl font-bold text-red-600">{stats.rapidRequest}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-slate-200">
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<Clock className="w-8 h-8 text-yellow-600" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-500">Pending</p>
|
|
||||||
<p className="text-3xl font-bold text-yellow-600">{stats.pending}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-slate-200">
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<CalendarIcon className="w-8 h-8 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-500">Confirmed</p>
|
|
||||||
<p className="text-3xl font-bold text-green-600">{stats.confirmed}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-slate-200">
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<Users className="w-8 h-8 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-500">Completed</p>
|
|
||||||
<p className="text-3xl font-bold text-blue-600">{stats.completed}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter Tabs */}
|
|
||||||
<div className="flex gap-2 mb-6 flex-wrap">
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "all" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("all")}
|
|
||||||
className={statusFilter === "all" ? "bg-[#0A39DF]" : ""}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "rapid_request" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("rapid_request")}
|
|
||||||
className={statusFilter === "rapid_request" ? "bg-red-600 hover:bg-red-700" : ""}
|
|
||||||
>
|
|
||||||
<Zap className="w-4 h-4 mr-2" />
|
|
||||||
Rapid Request
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "pending" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("pending")}
|
|
||||||
className={statusFilter === "pending" ? "bg-[#0A39DF]" : ""}
|
|
||||||
>
|
|
||||||
Pending
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "confirmed" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("confirmed")}
|
|
||||||
className={statusFilter === "confirmed" ? "bg-[#0A39DF]" : ""}
|
|
||||||
>
|
|
||||||
Confirmed
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "completed" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("completed")}
|
|
||||||
className={statusFilter === "completed" ? "bg-[#0A39DF]" : ""}
|
|
||||||
>
|
|
||||||
Completed
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Orders List */}
|
|
||||||
<div className="grid grid-cols-1 gap-6">
|
|
||||||
{filteredEvents.length > 0 ? (
|
|
||||||
filteredEvents.map((event) => (
|
|
||||||
<Card key={event.id} className="border-slate-200 hover:shadow-lg transition-shadow">
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<h3 className="text-xl font-bold text-[#1C323E]">{event.event_name}</h3>
|
|
||||||
<Badge className={getStatusColor(event.status)}>
|
|
||||||
{event.status}
|
|
||||||
</Badge>
|
|
||||||
{event.is_rapid_request && (
|
|
||||||
<Badge className="bg-red-100 text-red-700 border-red-200 border">
|
|
||||||
<Zap className="w-3 h-3 mr-1" />
|
|
||||||
Rapid Request
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{event.include_backup && (
|
|
||||||
<Badge className="bg-green-100 text-green-700 border-green-200 border">
|
|
||||||
🛡️ {event.backup_staff_count || 0} Backup Staff
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm text-slate-600 mb-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CalendarIcon className="w-4 h-4" />
|
|
||||||
<span>{event.date ? format(new Date(event.date), 'PPP') : 'Date TBD'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<MapPin className="w-4 h-4" />
|
|
||||||
<span>{event.event_location || event.hub || 'Location TBD'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Users className="w-4 h-4" />
|
|
||||||
<span>{event.assigned || 0} of {event.requested || 0} staff</span>
|
|
||||||
</div>
|
|
||||||
{event.total && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<DollarSign className="w-4 h-4" />
|
|
||||||
<span className="font-semibold">${event.total.toLocaleString()}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</TableCell>
|
||||||
</div>
|
<TableCell> {/* Date cell */}
|
||||||
</div>
|
<div>
|
||||||
<div className="flex gap-2">
|
<p className="font-semibold text-slate-900">
|
||||||
<Button
|
{safeFormatDate(order.date, 'MMM dd, yyyy')}
|
||||||
onClick={() => navigate(createPageUrl("EventDetail") + `?id=${event.id}`)}
|
</p>
|
||||||
variant="outline"
|
<p className="text-xs text-slate-500">
|
||||||
className="hover:bg-[#0A39DF] hover:text-white"
|
{safeFormatDate(order.date, 'EEEE')}
|
||||||
>
|
</p>
|
||||||
View Details
|
</div>
|
||||||
</Button>
|
</TableCell>
|
||||||
<Button
|
<TableCell> {/* Location cell */}
|
||||||
onClick={() => handleQuickReorder(event)}
|
<div className="flex items-center gap-1.5 text-sm text-slate-600">
|
||||||
className="bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 text-white shadow-lg"
|
<MapPin className="w-3.5 h-3.5 text-slate-400" />
|
||||||
size="lg"
|
{order.hub || order.event_location || "—"}
|
||||||
>
|
</div>
|
||||||
<RefreshCw className="w-5 h-5 mr-2" />
|
</TableCell>
|
||||||
Reorder
|
<TableCell> {/* Time cell */}
|
||||||
</Button>
|
<div className="flex items-center gap-1 text-sm text-slate-600">
|
||||||
</div>
|
<Clock className="w-3.5 h-3.5 text-slate-400" />
|
||||||
</div>
|
{startTime} - {endTime}
|
||||||
{event.notes && (
|
</div>
|
||||||
<div className="mt-3 p-3 bg-slate-50 rounded-lg">
|
</TableCell>
|
||||||
<p className="text-sm text-slate-600">{event.notes}</p>
|
<TableCell> {/* Status cell */}
|
||||||
</div>
|
{getStatusBadge(order)}
|
||||||
)}
|
</TableCell>
|
||||||
</CardContent>
|
<TableCell className="text-center"> {/* Staff cell */}
|
||||||
</Card>
|
<div className="flex flex-col items-center gap-1">
|
||||||
))
|
<Badge className={assignment.badgeClass}>
|
||||||
) : (
|
{assignment.assigned} / {assignment.requested}
|
||||||
<Card className="border-slate-200">
|
</Badge>
|
||||||
<CardContent className="p-12 text-center">
|
<span className="text-[10px] text-slate-500 font-medium">
|
||||||
<FileText className="w-16 h-16 mx-auto text-slate-300 mb-4" />
|
{assignment.percentage}%
|
||||||
<h3 className="text-lg font-semibold text-slate-700 mb-2">No orders found</h3>
|
</span>
|
||||||
<p className="text-slate-500 mb-6">Get started by creating your first order</p>
|
</div>
|
||||||
<Button
|
</TableCell>
|
||||||
onClick={() => navigate(createPageUrl("CreateEvent"))}
|
<TableCell className="text-center"> {/* Invoice cell */}
|
||||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
<div className="flex items-center justify-center">
|
||||||
>
|
<Button // Changed from a div to a Button for better accessibility
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
variant="ghost"
|
||||||
Create Order
|
size="icon"
|
||||||
</Button>
|
onClick={() => invoiceReady && navigate(createPageUrl('Invoices'))}
|
||||||
</CardContent>
|
className={`w-10 h-10 rounded-full flex items-center justify-center ${invoiceReady ? 'bg-blue-100' : 'bg-slate-100'} ${invoiceReady ? 'cursor-pointer hover:bg-blue-200' : 'cursor-not-allowed opacity-50'}`}
|
||||||
</Card>
|
disabled={!invoiceReady}
|
||||||
)}
|
title={invoiceReady ? "View Invoice" : "Invoice not available"}
|
||||||
</div>
|
>
|
||||||
|
<FileText className={`w-5 h-5 ${invoiceReady ? 'text-blue-600' : 'text-slate-400'}`} />
|
||||||
{/* Quick Reorder Modal */}
|
</Button>
|
||||||
{selectedEvent && (
|
</div>
|
||||||
<QuickReorderModal
|
</TableCell>
|
||||||
event={selectedEvent}
|
<TableCell> {/* Actions cell */}
|
||||||
open={reorderModalOpen}
|
<div className="flex items-center justify-center gap-1">
|
||||||
onOpenChange={setReorderModalOpen}
|
<Button
|
||||||
/>
|
variant="ghost"
|
||||||
)}
|
size="icon"
|
||||||
|
onClick={() => navigate(createPageUrl(`EventDetail?id=${order.id}`))}
|
||||||
|
className="hover:bg-slate-100"
|
||||||
|
title="View details"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{canEditOrder(order) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(createPageUrl(`EditEvent?id=${order.id}`))}
|
||||||
|
className="hover:bg-slate-100"
|
||||||
|
title="Edit order"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" /> {/* Changed from Edit2 */}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canCancelOrder(order) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleCancelOrder(order)} // Updated
|
||||||
|
className="hover:bg-red-50 hover:text-red-600"
|
||||||
|
title="Cancel order"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}> {/* Updated open and onOpenChange */}
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
Cancel Order?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to cancel this order? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{orderToCancel && ( // Using orderToCancel
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
|
||||||
|
<p className="font-bold text-slate-900">{orderToCancel.event_name}</p>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
{orderToCancel.date ? format(new Date(orderToCancel.date), "MMMM d, yyyy") : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
{orderToCancel.hub || orderToCancel.event_location}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCancelDialogOpen(false)} // Updated
|
||||||
|
>
|
||||||
|
Keep Order
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={confirmCancel}
|
||||||
|
disabled={cancelOrderMutation.isPending}
|
||||||
|
>
|
||||||
|
{cancelOrderMutation.isPending ? "Canceling..." : "Yes, Cancel Order"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,38 @@
|
|||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { base44 } from "@/api/base44Client";
|
import { base44 } from "@/api/base44Client";
|
||||||
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { createPageUrl } from "@/utils";
|
import { createPageUrl } from "@/utils";
|
||||||
import EventFormWizard from "@/components/events/EventFormWizard";
|
import EventFormWizard from "@/components/events/EventFormWizard";
|
||||||
import AIOrderAssistant from "@/components/events/AIOrderAssistant";
|
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Sparkles, FileText, X } from "lucide-react";
|
import { X, AlertTriangle } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { detectAllConflicts, ConflictAlert } from "@/components/scheduling/ConflictDetection";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
export default function CreateEvent() {
|
export default function CreateEvent() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [useAI, setUseAI] = useState(false);
|
const [pendingEvent, setPendingEvent] = React.useState(null);
|
||||||
const [aiExtractedData, setAiExtractedData] = useState(null);
|
const [showConflictWarning, setShowConflictWarning] = React.useState(false);
|
||||||
|
|
||||||
const { data: currentUser } = useQuery({
|
const { data: currentUser } = useQuery({
|
||||||
queryKey: ['current-user-create-event'],
|
queryKey: ['current-user-create-event'],
|
||||||
queryFn: () => base44.auth.me(),
|
queryFn: () => base44.auth.me(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: allEvents = [] } = useQuery({
|
||||||
|
queryKey: ['events-for-conflict-check'],
|
||||||
|
queryFn: () => base44.entities.Event.list(),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
const createEventMutation = useMutation({
|
const createEventMutation = useMutation({
|
||||||
mutationFn: (eventData) => base44.entities.Event.create(eventData),
|
mutationFn: (eventData) => base44.entities.Event.create(eventData),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['client-events'] });
|
||||||
toast({
|
toast({
|
||||||
title: "✅ Event Created",
|
title: "✅ Event Created",
|
||||||
description: "Your event has been created successfully.",
|
description: "Your event has been created successfully.",
|
||||||
@@ -42,107 +49,98 @@ export default function CreateEvent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (eventData) => {
|
const handleSubmit = (eventData) => {
|
||||||
createEventMutation.mutate(eventData);
|
// Detect conflicts before creating
|
||||||
|
const conflicts = detectAllConflicts(eventData, allEvents);
|
||||||
|
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
setPendingEvent({ ...eventData, detected_conflicts: conflicts });
|
||||||
|
setShowConflictWarning(true);
|
||||||
|
} else {
|
||||||
|
createEventMutation.mutate(eventData);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAIDataExtracted = (extractedData) => {
|
const handleConfirmWithConflicts = () => {
|
||||||
setAiExtractedData(extractedData);
|
if (pendingEvent) {
|
||||||
setUseAI(false);
|
createEventMutation.mutate(pendingEvent);
|
||||||
|
setShowConflictWarning(false);
|
||||||
|
setPendingEvent(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelConflicts = () => {
|
||||||
|
setShowConflictWarning(false);
|
||||||
|
setPendingEvent(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
||||||
<div className="max-w-7xl mx-auto p-4 md:p-8">
|
<div className="max-w-7xl mx-auto p-4 md:p-8">
|
||||||
{/* Header with AI Toggle */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-[#1C323E]">Create New Order</h1>
|
<h1 className="text-3xl font-bold text-[#1C323E]">Create Standard Order</h1>
|
||||||
<p className="text-slate-600 mt-1">
|
<p className="text-slate-600 mt-1">
|
||||||
{useAI ? "Use AI to create your order naturally" : "Fill out the form to create your order"}
|
Fill out the details for your planned event
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant={useAI ? "default" : "outline"}
|
onClick={() => navigate(createPageUrl("ClientDashboard"))}
|
||||||
onClick={() => setUseAI(true)}
|
>
|
||||||
className={useAI ? "bg-gradient-to-r from-[#0A39DF] to-purple-600" : ""}
|
<X className="w-4 h-4" />
|
||||||
>
|
</Button>
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
|
||||||
AI Assistant
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={!useAI ? "default" : "outline"}
|
|
||||||
onClick={() => setUseAI(false)}
|
|
||||||
className={!useAI ? "bg-[#1C323E]" : ""}
|
|
||||||
>
|
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
|
||||||
Form
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => navigate(createPageUrl("Events"))}
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Assistant Interface */}
|
{/* Conflict Warning Modal */}
|
||||||
<AnimatePresence>
|
{showConflictWarning && pendingEvent && (
|
||||||
{useAI && (
|
<Card className="mb-6 border-2 border-orange-500">
|
||||||
<motion.div
|
<CardContent className="p-6">
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
<div className="flex items-start gap-4 mb-4">
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
<div className="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
<AlertTriangle className="w-6 h-6 text-orange-600" />
|
||||||
>
|
|
||||||
<AIOrderAssistant
|
|
||||||
onOrderDataExtracted={handleAIDataExtracted}
|
|
||||||
onClose={() => setUseAI(false)}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Wizard Form */}
|
|
||||||
{!useAI && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
>
|
|
||||||
{aiExtractedData && (
|
|
||||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Sparkles className="w-5 h-5 text-green-600" />
|
|
||||||
<span className="font-semibold text-green-900">AI Pre-filled Data</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-green-700 mb-3">
|
<div>
|
||||||
The form has been pre-filled with information from your conversation. Review and edit as needed.
|
<h3 className="font-bold text-lg text-slate-900 mb-1">
|
||||||
</p>
|
Scheduling Conflicts Detected
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
This event has {pendingEvent.detected_conflicts.length} potential conflict{pendingEvent.detected_conflicts.length !== 1 ? 's' : ''}
|
||||||
|
with existing bookings. Review the conflicts below and decide how to proceed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<ConflictAlert conflicts={pendingEvent.detected_conflicts} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
onClick={handleCancelConflicts}
|
||||||
onClick={() => {
|
|
||||||
setAiExtractedData(null);
|
|
||||||
setUseAI(true);
|
|
||||||
}}
|
|
||||||
className="border-green-300 text-green-700 hover:bg-green-100"
|
|
||||||
>
|
>
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
Go Back & Edit
|
||||||
Chat with AI Again
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmWithConflicts}
|
||||||
|
className="bg-orange-600 hover:bg-orange-700"
|
||||||
|
>
|
||||||
|
Create Anyway
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<EventFormWizard
|
|
||||||
event={aiExtractedData}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
isSubmitting={createEventMutation.isPending}
|
|
||||||
currentUser={currentUser}
|
|
||||||
onCancel={() => navigate(createPageUrl("Events"))}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<EventFormWizard
|
||||||
|
event={null}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isSubmitting={createEventMutation.isPending}
|
||||||
|
currentUser={currentUser}
|
||||||
|
onCancel={() => navigate(createPageUrl("ClientDashboard"))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,11 +6,104 @@ import { Link, useNavigate } from "react-router-dom";
|
|||||||
import { createPageUrl } from "@/utils";
|
import { createPageUrl } from "@/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
import { Users, Building2, UserPlus, TrendingUp, MapPin, Calendar, DollarSign, Award, Target, BarChart3, Shield, Leaf } from "lucide-react";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Users, Building2, UserPlus, TrendingUp, MapPin, Calendar, DollarSign, Award, Target, BarChart3, Shield, Leaf, Eye, Edit, Sparkles, Zap, Clock, AlertTriangle, CheckCircle, FileText, X } from "lucide-react";
|
||||||
import StatsCard from "@/components/staff/StatsCard";
|
import StatsCard from "@/components/staff/StatsCard";
|
||||||
import EcosystemWheel from "@/components/dashboard/EcosystemWheel";
|
import EcosystemWheel from "@/components/dashboard/EcosystemWheel";
|
||||||
import QuickMetrics from "@/components/dashboard/QuickMetrics";
|
import QuickMetrics from "@/components/dashboard/QuickMetrics";
|
||||||
import PageHeader from "@/components/common/PageHeader";
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
|
import { format, parseISO, isValid, isSameDay, startOfDay } from "date-fns";
|
||||||
|
|
||||||
|
const safeParseDate = (dateString) => {
|
||||||
|
if (!dateString) return null;
|
||||||
|
try {
|
||||||
|
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
|
||||||
|
return isValid(date) ? date : null;
|
||||||
|
} catch { return null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeFormatDate = (dateString, formatStr) => {
|
||||||
|
const date = safeParseDate(dateString);
|
||||||
|
if (!date) return "-";
|
||||||
|
try { return format(date, formatStr); } catch { return "-"; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertTo12Hour = (time24) => {
|
||||||
|
if (!time24) return "-";
|
||||||
|
try {
|
||||||
|
const [hours, minutes] = time24.split(':');
|
||||||
|
const hour = parseInt(hours);
|
||||||
|
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||||
|
const hour12 = hour % 12 || 12;
|
||||||
|
return `${hour12}:${minutes} ${ampm}`;
|
||||||
|
} catch {
|
||||||
|
return time24;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (event) => {
|
||||||
|
if (event.is_rapid) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md">
|
||||||
|
<Zap className="w-3.5 h-3.5 fill-white" />
|
||||||
|
RAPID
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
'Draft': { bg: 'bg-slate-500', icon: FileText },
|
||||||
|
'Pending': { bg: 'bg-amber-500', icon: Clock },
|
||||||
|
'Partial Staffed': { bg: 'bg-orange-500', icon: AlertTriangle },
|
||||||
|
'Fully Staffed': { bg: 'bg-emerald-500', icon: CheckCircle },
|
||||||
|
'Active': { bg: 'bg-blue-500', icon: Users },
|
||||||
|
'Completed': { bg: 'bg-slate-400', icon: CheckCircle },
|
||||||
|
'Canceled': { bg: 'bg-red-500', icon: X },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[event.status] || { bg: 'bg-slate-400', icon: Clock };
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`inline-flex items-center gap-2 ${config.bg} text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md`}>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{event.status}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventTimes = (event) => {
|
||||||
|
const firstShift = event.shifts?.[0];
|
||||||
|
const rolesInFirstShift = firstShift?.roles || [];
|
||||||
|
|
||||||
|
let startTime = null;
|
||||||
|
let endTime = null;
|
||||||
|
|
||||||
|
if (rolesInFirstShift.length > 0) {
|
||||||
|
startTime = rolesInFirstShift[0].start_time || null;
|
||||||
|
endTime = rolesInFirstShift[0].end_time || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startTime: startTime ? convertTo12Hour(startTime) : "-",
|
||||||
|
endTime: endTime ? convertTo12Hour(endTime) : "-"
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAssignmentStatus = (event) => {
|
||||||
|
const totalRequested = event.shifts?.reduce((accShift, shift) => {
|
||||||
|
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
|
||||||
|
}, 0) || 0;
|
||||||
|
|
||||||
|
const assigned = event.assigned_staff?.length || 0;
|
||||||
|
const fillPercent = totalRequested > 0 ? Math.round((assigned / totalRequested) * 100) : 0;
|
||||||
|
|
||||||
|
if (assigned === 0) return { color: 'bg-slate-200 text-slate-600', text: '0', percent: '0%', status: 'empty' };
|
||||||
|
if (totalRequested > 0 && assigned >= totalRequested) return { color: 'bg-emerald-500 text-white', text: assigned, percent: '100%', status: 'full' };
|
||||||
|
if (totalRequested > 0 && assigned < totalRequested) return { color: 'bg-slate-200 text-slate-600', text: assigned, percent: `${fillPercent}%`, status: 'partial' };
|
||||||
|
return { color: 'bg-slate-200 text-slate-600', text: assigned, percent: '0%', status: 'partial' };
|
||||||
|
};
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -28,6 +121,13 @@ export default function Dashboard() {
|
|||||||
initialData: [],
|
initialData: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Filter events for today only
|
||||||
|
const today = startOfDay(new Date());
|
||||||
|
const todaysEvents = events.filter(event => {
|
||||||
|
const eventDate = safeParseDate(event.date);
|
||||||
|
return eventDate && isSameDay(eventDate, today);
|
||||||
|
});
|
||||||
|
|
||||||
const recentStaff = staff.slice(0, 6);
|
const recentStaff = staff.slice(0, 6);
|
||||||
const uniqueDepartments = [...new Set(staff.map(s => s.department).filter(Boolean))];
|
const uniqueDepartments = [...new Set(staff.map(s => s.department).filter(Boolean))];
|
||||||
const uniqueLocations = [...new Set(staff.map(s => s.hub_location).filter(Boolean))];
|
const uniqueLocations = [...new Set(staff.map(s => s.hub_location).filter(Boolean))];
|
||||||
@@ -105,7 +205,7 @@ export default function Dashboard() {
|
|||||||
<Link to={createPageUrl("Events")}>
|
<Link to={createPageUrl("Events")}>
|
||||||
<Button className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg">
|
<Button className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg">
|
||||||
<Calendar className="w-5 h-5 mr-2" />
|
<Calendar className="w-5 h-5 mr-2" />
|
||||||
View All Events
|
View All Orders
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
@@ -143,6 +243,133 @@ export default function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Today's Orders Section */}
|
||||||
|
<Card className="mb-8 border-slate-200 shadow-lg">
|
||||||
|
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-[#1C323E] flex items-center gap-2">
|
||||||
|
<Calendar className="w-6 h-6 text-[#0A39DF]" />
|
||||||
|
Today's Orders - {format(today, 'EEEE, MMMM d, yyyy')}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">Orders scheduled for today only</p>
|
||||||
|
</div>
|
||||||
|
<Link to={createPageUrl("Events")}>
|
||||||
|
<Button variant="outline" className="border-slate-300">
|
||||||
|
View All Orders
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{todaysEvents.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-slate-500">
|
||||||
|
<Calendar className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
||||||
|
<p className="font-medium">No orders scheduled for today</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b">
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide h-10">BUSINESS</TableHead>
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">HUB</TableHead>
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">EVENT</TableHead>
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">DATE & TIME</TableHead>
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">STATUS</TableHead>
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">REQUESTED</TableHead>
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ASSIGNED</TableHead>
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ACTIONS</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{todaysEvents.map((event) => {
|
||||||
|
const assignmentStatus = getAssignmentStatus(event);
|
||||||
|
const eventTimes = getEventTimes(event);
|
||||||
|
const eventDate = safeParseDate(event.date);
|
||||||
|
const dayOfWeek = eventDate ? format(eventDate, 'EEEE') : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={event.id} className="hover:bg-slate-50 transition-colors border-b">
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<p className="text-sm text-slate-700 font-medium">{event.business_name || "—"}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-slate-500">
|
||||||
|
<MapPin className="w-3.5 h-3.5" />
|
||||||
|
{event.hub || event.event_location || "Main Hub"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<p className="font-semibold text-slate-900 text-sm">{event.event_name}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-sm text-slate-900 font-semibold">{eventDate ? format(eventDate, 'MM.dd.yyyy') : '-'}</p>
|
||||||
|
<p className="text-xs text-slate-500">{dayOfWeek}</p>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-slate-600 mt-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>{eventTimes.startTime} - {eventTimes.endTime}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-3">
|
||||||
|
{getStatusBadge(event)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center py-3">
|
||||||
|
<span className="font-semibold text-slate-700 text-sm">{event.requested || 0}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center py-3">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<div className={`w-10 h-10 rounded-full ${assignmentStatus.color} flex items-center justify-center font-bold text-sm`}>
|
||||||
|
{assignmentStatus.text}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-500 font-medium">{assignmentStatus.percent}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(createPageUrl(`EventDetail?id=${event.id}`))}
|
||||||
|
className="hover:bg-slate-100 h-8 w-8"
|
||||||
|
title="View"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
|
||||||
|
className="hover:bg-slate-100 h-8 w-8"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{event.invoice_id && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(createPageUrl(`Invoices?id=${event.invoice_id}`))}
|
||||||
|
className="hover:bg-slate-100 h-8 w-8"
|
||||||
|
title="View Invoice"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 text-blue-600" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Ecosystem Puzzle */}
|
{/* Ecosystem Puzzle */}
|
||||||
<Card className="mb-8 border-slate-200 shadow-xl overflow-hidden">
|
<Card className="mb-8 border-slate-200 shadow-xl overflow-hidden">
|
||||||
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
|
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
|
||||||
|
|||||||
@@ -1,52 +1,48 @@
|
|||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { base44 } from "@/api/base44Client";
|
import { base44 } from "@/api/base44Client";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { createPageUrl } from "@/utils";
|
import { createPageUrl } from "@/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { ArrowLeft, Bell, RefreshCw } from "lucide-react";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import ShiftCard from "@/components/events/ShiftCard";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { ArrowLeft, Calendar, MapPin, Users, DollarSign, Send, Edit3, X, AlertTriangle } from "lucide-react";
|
||||||
|
import ShiftCard from "@/components/events/ShiftCard";
|
||||||
|
import OrderStatusBadge from "@/components/orders/OrderStatusBadge";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
const statusColors = {
|
const safeFormatDate = (dateString) => {
|
||||||
Draft: "bg-gray-100 text-gray-800",
|
if (!dateString) return "—";
|
||||||
Active: "bg-green-100 text-green-800",
|
|
||||||
Pending: "bg-purple-100 text-purple-800",
|
|
||||||
Confirmed: "bg-blue-100 text-blue-800",
|
|
||||||
Completed: "bg-slate-100 text-slate-800",
|
|
||||||
Canceled: "bg-red-100 text-red-800" // Added Canceled status for completeness
|
|
||||||
};
|
|
||||||
|
|
||||||
// Safe date formatter
|
|
||||||
const safeFormatDate = (dateString, formatStr) => {
|
|
||||||
if (!dateString) return "-";
|
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateString);
|
return format(new Date(dateString), "MMMM d, yyyy");
|
||||||
if (isNaN(date.getTime())) return "-";
|
|
||||||
return format(date, formatStr);
|
|
||||||
} catch {
|
} catch {
|
||||||
return "-";
|
return "—";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EventDetail() {
|
export default function EventDetail() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showNotifyDialog, setShowNotifyDialog] = useState(false);
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const eventId = urlParams.get('id');
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [notifyDialog, setNotifyDialog] = useState(false);
|
||||||
|
const [cancelDialog, setCancelDialog] = useState(false);
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const eventId = urlParams.get("id");
|
||||||
|
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ['current-user-event-detail'],
|
||||||
|
queryFn: () => base44.auth.me(),
|
||||||
|
});
|
||||||
|
|
||||||
const { data: allEvents, isLoading } = useQuery({
|
const { data: allEvents, isLoading } = useQuery({
|
||||||
queryKey: ['events'],
|
queryKey: ['events'],
|
||||||
@@ -54,208 +50,314 @@ export default function EventDetail() {
|
|||||||
initialData: [],
|
initialData: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: shifts } = useQuery({
|
|
||||||
queryKey: ['shifts', eventId],
|
|
||||||
queryFn: () => base44.entities.Shift.filter({ event_id: eventId }),
|
|
||||||
initialData: [],
|
|
||||||
enabled: !!eventId
|
|
||||||
});
|
|
||||||
|
|
||||||
const event = allEvents.find(e => e.id === eventId);
|
const event = allEvents.find(e => e.id === eventId);
|
||||||
|
|
||||||
const handleReorder = () => {
|
// Cancel order mutation
|
||||||
if (!event) return; // Should not happen if event is loaded, but for safety
|
const cancelOrderMutation = useMutation({
|
||||||
|
mutationFn: () => base44.entities.Event.update(eventId, { status: "Canceled" }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['all-events-client'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Order Canceled",
|
||||||
|
description: "Your order has been canceled successfully",
|
||||||
|
});
|
||||||
|
setCancelDialog(false);
|
||||||
|
navigate(createPageUrl("ClientOrders"));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: "❌ Failed to Cancel",
|
||||||
|
description: "Could not cancel order. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const reorderData = {
|
const handleNotifyStaff = async () => {
|
||||||
event_name: event.event_name,
|
const assignedStaff = event?.assigned_staff || [];
|
||||||
business_id: event.business_id,
|
|
||||||
business_name: event.business_name,
|
|
||||||
hub: event.hub,
|
|
||||||
event_location: event.event_location,
|
|
||||||
event_type: event.event_type,
|
|
||||||
requested: event.requested,
|
|
||||||
client_name: event.client_name,
|
|
||||||
client_email: event.client_email,
|
|
||||||
client_phone: event.client_phone,
|
|
||||||
client_address: event.client_address,
|
|
||||||
notes: event.notes,
|
|
||||||
};
|
|
||||||
|
|
||||||
sessionStorage.setItem('reorderData', JSON.stringify(reorderData));
|
|
||||||
|
|
||||||
toast({
|
for (const staff of assignedStaff) {
|
||||||
title: "Reordering Event",
|
try {
|
||||||
description: `Creating new order based on "${event.event_name}"`,
|
await base44.integrations.Core.SendEmail({
|
||||||
});
|
to: staff.email || `${staff.staff_name}@example.com`,
|
||||||
|
subject: `Shift Update: ${event.event_name}`,
|
||||||
|
body: `You have an update for: ${event.event_name}\nDate: ${event.date}\nLocation: ${event.event_location || event.hub}\n\nPlease check the platform for details.`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send email:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
navigate(createPageUrl("CreateEvent") + "?reorder=true");
|
toast({
|
||||||
|
title: "✅ Notifications Sent",
|
||||||
|
description: `Notified ${assignedStaff.length} staff members`,
|
||||||
|
});
|
||||||
|
setNotifyDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading || !event) {
|
const isClient = user?.user_role === 'client' ||
|
||||||
|
event?.created_by === user?.email ||
|
||||||
|
event?.client_email === user?.email;
|
||||||
|
|
||||||
|
const canEditOrder = () => {
|
||||||
|
if (!event) return false;
|
||||||
|
const eventDate = new Date(event.date);
|
||||||
|
const now = new Date();
|
||||||
|
return isClient &&
|
||||||
|
event.status !== "Completed" &&
|
||||||
|
event.status !== "Canceled" &&
|
||||||
|
eventDate > now;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canCancelOrder = () => {
|
||||||
|
if (!event) return false;
|
||||||
|
return isClient &&
|
||||||
|
event.status !== "Completed" &&
|
||||||
|
event.status !== "Canceled";
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full" />
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
<p className="text-xl font-semibold text-slate-900 mb-4">Event not found</p>
|
||||||
|
<Link to={createPageUrl("Events")}>
|
||||||
|
<Button variant="outline">Back to Events</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get shifts from event.shifts array (primary source)
|
||||||
|
const eventShifts = event.shifts || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
<div className="p-4 md:p-8">
|
||||||
<div className="max-w-[1600px] mx-auto">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate(createPageUrl("Events"))}>
|
{/* Header */}
|
||||||
<ArrowLeft className="w-5 h-5" />
|
<div className="flex items-center justify-between">
|
||||||
</Button>
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-2xl font-bold">{event.event_name}</h1>
|
<Button
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
variant="ghost"
|
||||||
{(event.status === "Completed" || event.status === "Canceled") && (
|
size="icon"
|
||||||
<Button
|
onClick={() => navigate(-1)}
|
||||||
onClick={handleReorder}
|
>
|
||||||
className="bg-green-600 hover:bg-green-700 text-white"
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">{event.event_name}</h1>
|
||||||
|
<p className="text-slate-600 mt-1">Order Details & Information</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<OrderStatusBadge order={event} />
|
||||||
|
{canEditOrder() && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-white hover:bg-blue-50 border-2 border-blue-200 rounded-full text-blue-600 font-semibold text-base transition-all shadow-md hover:shadow-lg"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<Edit3 className="w-5 h-5" />
|
||||||
Reorder
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canCancelOrder() && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCancelDialog(true)}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-white hover:bg-red-50 border-2 border-red-200 rounded-full text-red-600 font-semibold text-base transition-all shadow-md hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isClient && event.assigned_staff?.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setNotifyDialog(true)}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
Notify Staff
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Bell className="w-5 h-5" />
|
|
||||||
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">
|
|
||||||
M
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
{/* Order Details Card */}
|
||||||
<Card className="border-slate-200">
|
<Card className="bg-white border border-slate-200 shadow-md">
|
||||||
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
|
<CardHeader className="border-b border-slate-100">
|
||||||
<CardTitle className="text-base">Order Details</CardTitle>
|
<CardTitle className="text-lg font-bold text-slate-900">Order Information</CardTitle>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">PO number</p>
|
|
||||||
<p className="font-medium">{event.po_number || event.po || "#RC-36559419"}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">Data</p>
|
|
||||||
<p className="font-medium">{safeFormatDate(event.date, "dd.MM.yyyy")}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">Status</p>
|
|
||||||
<Badge className={`${statusColors[event.status]} font-medium mt-1`}>
|
|
||||||
{event.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 pt-4">
|
|
||||||
<Button variant="outline" className="flex-1 text-sm">
|
|
||||||
Edit Order
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" className="flex-1 text-sm text-red-600 hover:text-red-700">
|
|
||||||
Cancel Order
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-slate-200 lg:col-span-2">
|
|
||||||
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
|
|
||||||
<CardTitle className="text-base">Client info</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 mb-1">Client name</p>
|
|
||||||
<p className="font-medium">{event.client_name || "Legendary"}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 mb-1">Number</p>
|
|
||||||
<p className="font-medium">{event.client_phone || "(408) 815-9180"}</p>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<p className="text-xs text-slate-500 mb-1">Address</p>
|
|
||||||
<p className="font-medium">{event.client_address || event.event_location || "848 E Dash Rd, Ste 264 E San Jose, CA 95122"}</p>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<p className="text-xs text-slate-500 mb-1">Email</p>
|
|
||||||
<p className="font-medium">{event.client_email || "order@legendarysweetssf.com"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border-slate-200 mb-6">
|
|
||||||
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
|
|
||||||
<CardTitle className="text-base">Event: {event.event_name}</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="grid grid-cols-2 gap-6 text-sm">
|
<div className="grid grid-cols-4 gap-6">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-slate-500">Hub</p>
|
<div className="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
|
||||||
<p className="font-medium">{event.hub || "Hub Name"}</p>
|
<Calendar className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Event Date</p>
|
||||||
|
<p className="font-bold text-slate-900">{safeFormatDate(event.date)}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-slate-500">Name of Department</p>
|
<div className="w-10 h-10 bg-purple-50 rounded-lg flex items-center justify-center">
|
||||||
<p className="font-medium">Department name</p>
|
<MapPin className="w-5 h-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Location</p>
|
||||||
|
<p className="font-bold text-slate-900">{event.hub || event.event_location || "—"}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-slate-500 mb-2">Order Addons</p>
|
<div className="w-10 h-10 bg-green-50 rounded-lg flex items-center justify-center">
|
||||||
<div className="flex gap-2">
|
<Users className="w-5 h-5 text-green-600" />
|
||||||
<Badge variant="outline" className="text-xs">Title</Badge>
|
</div>
|
||||||
<Badge variant="outline" className="text-xs">Travel Time</Badge>
|
<div>
|
||||||
<Badge variant="outline" className="text-xs">Meal Provided</Badge>
|
<p className="text-xs text-slate-500">Staff Assigned</p>
|
||||||
|
<p className="font-bold text-slate-900">
|
||||||
|
{event.assigned_staff?.length || 0} / {event.requested || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-amber-50 rounded-lg flex items-center justify-center">
|
||||||
|
<DollarSign className="w-5 h-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Total Cost</p>
|
||||||
|
<p className="font-bold text-slate-900">${(event.total || 0).toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="space-y-6">
|
{/* Client Information (if not client viewing) */}
|
||||||
{shifts.length > 0 ? (
|
{!isClient && (
|
||||||
shifts.map((shift, idx) => (
|
<Card className="bg-white border border-slate-200 shadow-md">
|
||||||
<ShiftCard
|
<CardHeader className="border-b border-slate-100">
|
||||||
key={shift.id}
|
<CardTitle className="text-lg font-bold text-slate-900">Client Information</CardTitle>
|
||||||
shift={shift}
|
</CardHeader>
|
||||||
onNotifyStaff={() => setShowNotifyDialog(true)}
|
<CardContent className="p-6">
|
||||||
/>
|
<div className="grid grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-1">Business Name</p>
|
||||||
|
<p className="font-bold text-slate-900">{event.business_name || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-1">Contact Name</p>
|
||||||
|
<p className="font-bold text-slate-900">{event.client_name || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-1">Contact Email</p>
|
||||||
|
<p className="font-bold text-slate-900">{event.client_email || "—"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shifts - Using event.shifts array */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">Event Shifts & Staff Assignment</h2>
|
||||||
|
{eventShifts.length > 0 ? (
|
||||||
|
eventShifts.map((shift, idx) => (
|
||||||
|
<ShiftCard key={idx} shift={shift} event={event} />
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<ShiftCard
|
<Card className="bg-white border border-slate-200">
|
||||||
shift={{
|
<CardContent className="p-12 text-center">
|
||||||
shift_name: "Shift 1",
|
<Users className="w-12 h-12 mx-auto mb-4 text-slate-400" />
|
||||||
assigned_staff: event.assigned_staff || [],
|
<p className="text-slate-600 font-medium mb-2">No shifts defined for this event</p>
|
||||||
location: event.event_location,
|
<p className="text-slate-500 text-sm">Add roles and staff requirements to get started</p>
|
||||||
unpaid_break: 0,
|
</CardContent>
|
||||||
price: 23,
|
</Card>
|
||||||
amount: 120
|
|
||||||
}}
|
|
||||||
onNotifyStaff={() => setShowNotifyDialog(true)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={showNotifyDialog} onOpenChange={setShowNotifyDialog}>
|
{/* Notes */}
|
||||||
<DialogContent className="sm:max-w-md">
|
{event.notes && (
|
||||||
<DialogHeader>
|
<Card className="bg-white border border-slate-200 shadow-md">
|
||||||
<div className="flex items-center justify-center mb-4">
|
<CardHeader className="border-b border-slate-100">
|
||||||
<div className="w-12 h-12 bg-pink-500 rounded-full flex items-center justify-center text-white font-bold text-xl">
|
<CardTitle className="text-lg font-bold text-slate-900">Additional Notes</CardTitle>
|
||||||
L
|
</CardHeader>
|
||||||
</div>
|
<CardContent className="p-6">
|
||||||
</div>
|
<p className="text-slate-700 whitespace-pre-wrap">{event.notes}</p>
|
||||||
<DialogTitle className="text-center">Notification Name</DialogTitle>
|
</CardContent>
|
||||||
<p className="text-center text-sm text-slate-600">
|
</Card>
|
||||||
Order #5 Admin (cancelled/replace) Want to proceed?
|
)}
|
||||||
</p>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter className="flex gap-3 sm:justify-center">
|
|
||||||
<Button variant="outline" onClick={() => setShowNotifyDialog(false)} className="flex-1">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setShowNotifyDialog(false)} className="flex-1 bg-blue-600 hover:bg-blue-700">
|
|
||||||
Proceed
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Notify Staff Dialog */}
|
||||||
|
<Dialog open={notifyDialog} onOpenChange={setNotifyDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Notify Assigned Staff</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Send notification to all {event.assigned_staff?.length || 0} assigned staff members about this event.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setNotifyDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleNotifyStaff} className="bg-blue-600 hover:bg-blue-700">
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
Send Notifications
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Cancel Order Dialog */}
|
||||||
|
<Dialog open={cancelDialog} onOpenChange={setCancelDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
Cancel Order?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to cancel this order? This action cannot be undone and the vendor will be notified immediately.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
|
||||||
|
<p className="font-bold text-slate-900">{event.event_name}</p>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
{safeFormatDate(event.date)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
{event.hub || event.event_location}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCancelDialog(false)}
|
||||||
|
>
|
||||||
|
Keep Order
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => cancelOrderMutation.mutate()}
|
||||||
|
disabled={cancelOrderMutation.isPending}
|
||||||
|
>
|
||||||
|
{cancelOrderMutation.isPending ? "Canceling..." : "Yes, Cancel Order"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ const statusColors = {
|
|||||||
'Overdue': 'bg-red-500 text-white',
|
'Overdue': 'bg-red-500 text-white',
|
||||||
'Resolved': 'bg-blue-500 text-white',
|
'Resolved': 'bg-blue-500 text-white',
|
||||||
'Paid': 'bg-green-500 text-white',
|
'Paid': 'bg-green-500 text-white',
|
||||||
'Reconciled': 'bg-yellow-600 text-white',
|
'Reconciled': 'bg-amber-600 text-white', // Changed from bg-yellow-600
|
||||||
'Disputed': 'bg-gray-500 text-white',
|
'Disputed': 'bg-gray-500 text-white',
|
||||||
'Verified': 'bg-teal-500 text-white',
|
'Verified': 'bg-teal-500 text-white',
|
||||||
'Pending': 'bg-amber-500 text-white',
|
'Pending': 'bg-amber-500 text-white',
|
||||||
@@ -161,7 +161,7 @@ export default function Invoices() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => setShowPaymentDialog(true)}
|
onClick={() => setShowPaymentDialog(true)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-yellow-400 hover:bg-yellow-500 text-slate-900 border-0 font-semibold"
|
className="bg-amber-500 hover:bg-amber-600 text-white border-0 font-semibold" // Changed className
|
||||||
>
|
>
|
||||||
Record Payment
|
Record Payment
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
|
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { createPageUrl } from "@/utils";
|
import { createPageUrl } from "@/utils";
|
||||||
@@ -10,7 +9,7 @@ import {
|
|||||||
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
|
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
|
||||||
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff,
|
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff,
|
||||||
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical,
|
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical,
|
||||||
Building2, Sparkles, CheckSquare, UserCheck, Store
|
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +31,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import ChatBubble from "@/components/chat/ChatBubble";
|
import ChatBubble from "@/components/chat/ChatBubble";
|
||||||
import RoleSwitcher from "@/components/dev/RoleSwitcher";
|
import RoleSwitcher from "@/components/dev/RoleSwitcher";
|
||||||
import NotificationPanel from "@/components/notifications/NotificationPanel";
|
import NotificationPanel from "@/components/notifications/NotificationPanel";
|
||||||
|
import { NotificationEngine } from "@/components/notifications/NotificationEngine";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
|
||||||
// Navigation items for each role
|
// Navigation items for each role
|
||||||
@@ -44,7 +44,9 @@ const roleNavigationMap = {
|
|||||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||||
|
{ title: "Onboarding", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||||
|
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||||
{ title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign },
|
{ title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign },
|
||||||
@@ -57,13 +59,14 @@ const roleNavigationMap = {
|
|||||||
],
|
],
|
||||||
procurement: [
|
procurement: [
|
||||||
{ title: "Dashboard", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
|
{ title: "Dashboard", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
|
||||||
|
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||||
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
|
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
|
||||||
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
||||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||||
|
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||||
{ title: "Compliance", url: createPageUrl("WorkforceCompliance"), icon: Shield },
|
{ title: "Compliance", url: createPageUrl("WorkforceCompliance"), icon: Shield },
|
||||||
{ title: "Orders", url: createPageUrl("Events"), icon: Clipboard },
|
|
||||||
{ title: "Rate Matrix", url: createPageUrl("VendorRateCard"), icon: DollarSign },
|
{ title: "Rate Matrix", url: createPageUrl("VendorRateCard"), icon: DollarSign },
|
||||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||||
@@ -71,25 +74,27 @@ const roleNavigationMap = {
|
|||||||
],
|
],
|
||||||
operator: [
|
operator: [
|
||||||
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
||||||
|
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||||
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
||||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
|
||||||
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
|
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
|
||||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||||
|
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||||
],
|
],
|
||||||
sector: [
|
sector: [
|
||||||
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
||||||
|
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
|
||||||
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
|
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
|
||||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||||
|
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||||
@@ -101,6 +106,7 @@ const roleNavigationMap = {
|
|||||||
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
|
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
|
||||||
{ title: "Compare Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign },
|
{ title: "Compare Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign },
|
||||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||||
|
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||||
@@ -108,16 +114,17 @@ const roleNavigationMap = {
|
|||||||
],
|
],
|
||||||
vendor: [
|
vendor: [
|
||||||
{ title: "Dashboard", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
|
{ title: "Dashboard", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
|
||||||
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
|
|
||||||
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
|
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
|
||||||
|
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
|
||||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
|
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
|
||||||
{ title: "Schedule", url: createPageUrl("WorkforceShifts"), icon: Calendar },
|
{ title: "Schedule", url: createPageUrl("WorkforceShifts"), icon: Calendar },
|
||||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||||
|
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||||
{ title: "Team", url: createPageUrl("Teams"), icon: UserCheck },
|
{ title: "Team", url: createPageUrl("Teams"), icon: UserCheck },
|
||||||
|
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||||
{ title: "Compliance", url: createPageUrl("VendorCompliance"), icon: Shield },
|
{ title: "Compliance", url: createPageUrl("VendorCompliance"), icon: Shield },
|
||||||
{ title: "Communications", url: createPageUrl("Messages"), icon: MessageSquare },
|
{ title: "Communications", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||||
{ title: "Leads", url: createPageUrl("Business"), icon: UserCheck },
|
{ title: "Leads", url: createPageUrl("Business"), icon: UserCheck },
|
||||||
{ title: "Tasks", url: createPageUrl("ActivityLog"), icon: CheckSquare },
|
|
||||||
{ title: "Business", url: createPageUrl("Business"), icon: Briefcase },
|
{ title: "Business", url: createPageUrl("Business"), icon: Briefcase },
|
||||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||||
{ title: "Audit Trail", url: createPageUrl("ActivityLog"), icon: Activity },
|
{ title: "Audit Trail", url: createPageUrl("ActivityLog"), icon: Activity },
|
||||||
@@ -125,8 +132,10 @@ const roleNavigationMap = {
|
|||||||
],
|
],
|
||||||
workforce: [
|
workforce: [
|
||||||
{ title: "Dashboard", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
|
{ title: "Dashboard", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
|
||||||
|
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||||
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
|
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
|
||||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||||
|
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||||
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
|
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
|
||||||
{ title: "Earnings", url: createPageUrl("WorkforceEarnings"), icon: DollarSign },
|
{ title: "Earnings", url: createPageUrl("WorkforceEarnings"), icon: DollarSign },
|
||||||
@@ -281,200 +290,34 @@ export default function Layout({ children }) {
|
|||||||
--muted: 241 245 249;
|
--muted: 241 245 249;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Calendar styling kept as is */
|
.rdp * { border-color: transparent !important; }
|
||||||
.rdp * {
|
.rdp-day { font-size: 0.875rem !important; min-width: 36px !important; height: 36px !important; border-radius: 50% !important; transition: all 0.2s ease !important; font-weight: 500 !important; position: relative !important; }
|
||||||
border-color: transparent !important;
|
.rdp-day button { width: 100% !important; height: 100% !important; border-radius: 50% !important; background-color: transparent !important; }
|
||||||
}
|
.rdp-day_range_start, .rdp-day_range_start > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||||
|
.rdp-day_range_end, .rdp-day_range_end > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||||
.rdp-day {
|
.rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end), .rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end) > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||||
font-size: 0.875rem !important;
|
.rdp-day_selected, .rdp-day_selected > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||||
min-width: 36px !important;
|
.rdp-day_range_middle, .rdp-day_range_middle > button { background-color: #dbeafe !important; background: #dbeafe !important; color: #2563eb !important; font-weight: 600 !important; border-radius: 0 !important; box-shadow: none !important; }
|
||||||
height: 36px !important;
|
.rdp-day_range_start.rdp-day_range_end, .rdp-day_range_start.rdp-day_range_end > button { border-radius: 50% !important; background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; }
|
||||||
border-radius: 50% !important;
|
.rdp-day:hover:not(.rdp-day_selected):not(.rdp-day_disabled):not(.rdp-day_range_start):not(.rdp-day_range_end):not(.rdp-day_range_middle) > button { background-color: #eff6ff !important; background: #eff6ff !important; color: #2563eb !important; border-radius: 50% !important; }
|
||||||
transition: all 0.2s ease !important;
|
.rdp-day_today:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::after { content: '' !important; position: absolute !important; bottom: 4px !important; left: 50% !important; transform: translateX(-50%) !important; width: 4px !important; height: 4px !important; background-color: #ec4899 !important; border-radius: 50% !important; z-index: 10 !important; }
|
||||||
font-weight: 500 !important;
|
.rdp-day_today.rdp-day_selected, .rdp-day_today.rdp-day_range_start, .rdp-day_today.rdp-day_range_end { color: white !important; }
|
||||||
position: relative !important;
|
.rdp-day_today.rdp-day_selected > button, .rdp-day_today.rdp-day_range_start > button, .rdp-day_today.rdp-day_range_end > button { color: white !important; }
|
||||||
}
|
.rdp-day_outside, .rdp-day_outside > button { color: #cbd5e1 !important; opacity: 0.5 !important; }
|
||||||
|
.rdp-day_disabled, .rdp-day_disabled > button { opacity: 0.3 !important; cursor: not-allowed !important; }
|
||||||
.rdp-day button {
|
.rdp-day_selected, .rdp-day_range_start, .rdp-day_range_end, .rdp-day_range_middle { opacity: 1 !important; visibility: visible !important; z-index: 5 !important; }
|
||||||
width: 100% !important;
|
.rdp-day.has-events:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::before { content: '' !important; position: absolute !important; top: 4px !important; right: 4px !important; width: 4px !important; height: 4px !important; background-color: #2563eb !important; border-radius: 50% !important; }
|
||||||
height: 100% !important;
|
.rdp-day_selected.has-events::before, .rdp-day_range_start.has-events::before, .rdp-day_range_end.has-events::before { background-color: white !important; }
|
||||||
border-radius: 50% !important;
|
.rdp-day_range_middle.has-events::before { background-color: #2563eb !important; }
|
||||||
background-color: transparent !important;
|
.rdp-head_cell { color: #64748b !important; font-weight: 600 !important; font-size: 0.75rem !important; text-transform: uppercase !important; padding: 8px 0 !important; }
|
||||||
}
|
.rdp-caption_label { font-size: 1rem !important; font-weight: 700 !important; color: #0f172a !important; }
|
||||||
|
.rdp-nav_button { width: 32px !important; height: 32px !important; border-radius: 6px !important; transition: all 0.2s ease !important; }
|
||||||
.rdp-day_range_start,
|
.rdp-nav_button:hover { background-color: #eff6ff !important; color: #2563eb !important; }
|
||||||
.rdp-day_range_start > button {
|
.rdp-months { gap: 2rem !important; }
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
.rdp-month { padding: 0.75rem !important; }
|
||||||
color: white !important;
|
.rdp-table { border-spacing: 0 !important; margin-top: 1rem !important; }
|
||||||
font-weight: 700 !important;
|
.rdp-cell { padding: 2px !important; }
|
||||||
border-radius: 50% !important;
|
.rdp-day[style*="background"] { background: transparent !important; }
|
||||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_range_end,
|
|
||||||
.rdp-day_range_end > button {
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
|
||||||
color: white !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end),
|
|
||||||
.rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end) > button {
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
|
||||||
color: white !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_selected,
|
|
||||||
.rdp-day_selected > button {
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
|
||||||
color: white !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_range_middle,
|
|
||||||
.rdp-day_range_middle > button {
|
|
||||||
background-color: #dbeafe !important;
|
|
||||||
background: #dbeafe !important;
|
|
||||||
color: #2563eb !important;
|
|
||||||
font-weight: 600 !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_range_start.rdp-day_range_end,
|
|
||||||
.rdp-day_range_start.rdp-day_range_end > button {
|
|
||||||
border-radius: 50% !important;
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day:hover:not(.rdp-day_selected):not(.rdp-day_disabled):not(.rdp-day_range_start):not(.rdp-day_range_end):not(.rdp-day_range_middle) > button {
|
|
||||||
background-color: #eff6ff !important;
|
|
||||||
background: #eff6ff !important;
|
|
||||||
color: #2563eb !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_today:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::after {
|
|
||||||
content: '' !important;
|
|
||||||
position: absolute !important;
|
|
||||||
bottom: 4px !important;
|
|
||||||
left: 50% !important;
|
|
||||||
transform: translateX(-50%) !important;
|
|
||||||
width: 4px !important;
|
|
||||||
height: 4px !important;
|
|
||||||
background-color: #ec4899 !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
z-index: 10 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_today.rdp-day_selected,
|
|
||||||
.rdp-day_today.rdp-day_range_start,
|
|
||||||
.rdp-day_today.rdp-day_range_end {
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_today.rdp-day_selected > button,
|
|
||||||
.rdp-day_today.rdp-day_range_start > button,
|
|
||||||
.rdp-day_today.rdp-day_range_end > button {
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_outside,
|
|
||||||
.rdp-day_outside > button {
|
|
||||||
color: #cbd5e1 !important;
|
|
||||||
opacity: 0.5 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_disabled,
|
|
||||||
.rdp-day_disabled > button {
|
|
||||||
opacity: 0.3 !important;
|
|
||||||
cursor: not-allowed !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_selected,
|
|
||||||
.rdp-day_range_start,
|
|
||||||
.rdp-day_range_end,
|
|
||||||
.rdp-day_range_middle {
|
|
||||||
opacity: 1 !important;
|
|
||||||
visibility: visible !important;
|
|
||||||
z-index: 5 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-head_cell {
|
|
||||||
color: #64748b !important;
|
|
||||||
font-weight: 600 !important;
|
|
||||||
font-size: 0.75rem !important;
|
|
||||||
text-transform: uppercase !important;
|
|
||||||
padding: 8px 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-caption_label {
|
|
||||||
font-size: 1rem !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
color: #0f172a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-nav_button {
|
|
||||||
width: 32px !important;
|
|
||||||
height: 32px !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
transition: all 0.2s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-nav_button:hover {
|
|
||||||
background-color: #eff6ff !important;
|
|
||||||
color: #2563eb !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day.has-events:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::before {
|
|
||||||
content: '' !important;
|
|
||||||
position: absolute !important;
|
|
||||||
top: 4px !important;
|
|
||||||
right: 4px !important;
|
|
||||||
width: 4px !important;
|
|
||||||
height: 4px !important;
|
|
||||||
background-color: #2563eb !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_selected.has-events::before,
|
|
||||||
.rdp-day_range_start.has-events::before,
|
|
||||||
.rdp-day_range_end.has-events::before {
|
|
||||||
background-color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_range_middle.has-events::before {
|
|
||||||
background-color: #2563eb !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-months {
|
|
||||||
gap: 2rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-month {
|
|
||||||
padding: 0.75rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-table {
|
|
||||||
border-spacing: 0 !important;
|
|
||||||
margin-top: 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-cell {
|
|
||||||
padding: 2px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day[style*="background"] {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
<header className="bg-white border-b border-slate-200 shadow-sm sticky top-0 z-30">
|
<header className="bg-white border-b border-slate-200 shadow-sm sticky top-0 z-30">
|
||||||
@@ -490,20 +333,14 @@ export default function Layout({ children }) {
|
|||||||
<div className="border-b border-slate-200 p-6">
|
<div className="border-b border-slate-200 p-6">
|
||||||
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-3 mb-4" onClick={() => setMobileMenuOpen(false)}>
|
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-3 mb-4" onClick={() => setMobileMenuOpen(false)}>
|
||||||
<div className="w-8 h-8 flex items-center justify-center">
|
<div className="w-8 h-8 flex items-center justify-center">
|
||||||
<img
|
<img src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png" alt="KROW Logo" className="w-full h-full object-contain" />
|
||||||
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png"
|
|
||||||
alt="KROW Logo"
|
|
||||||
className="w-full h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-bold text-[#1C323E]">KROW</h2>
|
<h2 className="font-bold text-[#1C323E]">KROW</h2>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-3 bg-slate-50 p-3 rounded-lg">
|
<div className="flex items-center gap-3 bg-slate-50 p-3 rounded-lg">
|
||||||
<Avatar className="w-10 h-10">
|
<Avatar className="w-10 h-10">
|
||||||
<AvatarImage src={userAvatar} alt={userName} />
|
<AvatarImage src={userAvatar} alt={userName} />
|
||||||
<AvatarFallback className="bg-[#0A39DF] text-white font-bold">
|
<AvatarFallback className="bg-[#0A39DF] text-white font-bold">{userInitial}</AvatarFallback>
|
||||||
{userInitial}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-semibold text-[#1C323E] text-sm truncate">{userName}</p>
|
<p className="font-semibold text-[#1C323E] text-sm truncate">{userName}</p>
|
||||||
@@ -515,13 +352,8 @@ export default function Layout({ children }) {
|
|||||||
<NavigationMenu location={location} userRole={userRole} closeSheet={() => setMobileMenuOpen(false)} />
|
<NavigationMenu location={location} userRole={userRole} closeSheet={() => setMobileMenuOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 border-t border-slate-200">
|
<div className="p-3 border-t border-slate-200">
|
||||||
<Button
|
<Button variant="ghost" className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {handleLogout(); setMobileMenuOpen(false);}}>
|
||||||
variant="ghost"
|
<LogOut className="w-4 h-4 mr-2" />Logout
|
||||||
className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
||||||
onClick={() => {handleLogout(); setMobileMenuOpen(false);}}
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4 mr-2" />
|
|
||||||
Logout
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -529,11 +361,7 @@ export default function Layout({ children }) {
|
|||||||
|
|
||||||
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||||
<div className="w-8 h-8 flex items-center justify-center">
|
<div className="w-8 h-8 flex items-center justify-center">
|
||||||
<img
|
<img src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png" alt="KROW Logo" className="w-full h-full object-contain" />
|
||||||
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png"
|
|
||||||
alt="KROW Logo"
|
|
||||||
className="w-full h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<h1 className="text-base font-bold text-[#1C323E]">KROW Workforce Control Tower</h1>
|
<h1 className="text-base font-bold text-[#1C323E]">KROW Workforce Control Tower</h1>
|
||||||
@@ -543,39 +371,22 @@ export default function Layout({ children }) {
|
|||||||
<div className="hidden md:flex flex-1 max-w-xl">
|
<div className="hidden md:flex flex-1 max-w-xl">
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input type="text" placeholder="Find employees, menu items, settings, and more..." className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#0A39DF] focus:border-transparent" />
|
||||||
type="text"
|
|
||||||
placeholder="Find employees, menu items, settings, and more..."
|
|
||||||
className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#0A39DF] focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button onClick={handleRefresh} className="flex items-center gap-2 px-3 py-2 text-slate-500 hover:text-[#0A39DF] hover:bg-blue-50 rounded-lg transition-all group" title="Unpublished changes - Click to refresh">
|
||||||
onClick={handleRefresh}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-slate-500 hover:text-[#0A39DF] hover:bg-blue-50 rounded-lg transition-all group"
|
|
||||||
title="Unpublished changes - Click to refresh"
|
|
||||||
>
|
|
||||||
<CloudOff className="w-5 h-5 group-hover:animate-pulse" />
|
<CloudOff className="w-5 h-5 group-hover:animate-pulse" />
|
||||||
<span className="hidden lg:inline text-sm font-medium">Unpublished changes</span>
|
<span className="hidden lg:inline text-sm font-medium">Unpublished changes</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="md:hidden hover:bg-slate-100" title="Search">
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="md:hidden hover:bg-slate-100"
|
|
||||||
title="Search"
|
|
||||||
>
|
|
||||||
<Search className="w-5 h-5 text-slate-600" />
|
<Search className="w-5 h-5 text-slate-600" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<button
|
<button onClick={() => setShowNotifications(true)} className="relative p-2 hover:bg-slate-100 rounded-lg transition-colors" title="Notifications">
|
||||||
onClick={() => setShowNotifications(true)}
|
|
||||||
className="relative p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
|
||||||
title="Notifications"
|
|
||||||
>
|
|
||||||
<Bell className="w-5 h-5 text-slate-600" />
|
<Bell className="w-5 h-5 text-slate-600" />
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
|
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
|
||||||
@@ -609,22 +420,21 @@ export default function Layout({ children }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("NotificationSettings")}>
|
||||||
|
<Bell className="w-4 h-4 mr-2" />Notification Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Settings")}>
|
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Settings")}>
|
||||||
<SettingsIcon className="w-4 h-4 mr-2" />
|
<SettingsIcon className="w-4 h-4 mr-2" />Settings
|
||||||
Settings
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Reports")}>
|
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Reports")}>
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
<FileText className="w-4 h-4 mr-2" />Reports
|
||||||
Reports
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("ActivityLog")}>
|
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("ActivityLog")}>
|
||||||
<Activity className="w-4 h-4 mr-2" />
|
<Activity className="w-4 h-4 mr-2" />Activity Log
|
||||||
Activity Log
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout} className="text-red-600">
|
<DropdownMenuItem onClick={handleLogout} className="text-red-600">
|
||||||
<LogOut className="w-4 h-4 mr-2" />
|
<LogOut className="w-4 h-4 mr-2" />Logout
|
||||||
Logout
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -634,9 +444,7 @@ export default function Layout({ children }) {
|
|||||||
<button className="flex items-center gap-2 hover:bg-slate-100 rounded-lg p-1.5 transition-colors" title={`${userName} - ${getRoleName(userRole)}`}>
|
<button className="flex items-center gap-2 hover:bg-slate-100 rounded-lg p-1.5 transition-colors" title={`${userName} - ${getRoleName(userRole)}`}>
|
||||||
<Avatar className="w-8 h-8">
|
<Avatar className="w-8 h-8">
|
||||||
<AvatarImage src={userAvatar} alt={userName} />
|
<AvatarImage src={userAvatar} alt={userName} />
|
||||||
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white font-bold text-sm">
|
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white font-bold text-sm">{userInitial}</AvatarFallback>
|
||||||
{userInitial}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="hidden lg:block text-sm font-medium text-slate-700">{userName.split(' ')[0]}</span>
|
<span className="hidden lg:block text-sm font-medium text-slate-700">{userName.split(' ')[0]}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -651,12 +459,10 @@ export default function Layout({ children }) {
|
|||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => window.location.href = getDashboardUrl(userRole)}>
|
<DropdownMenuItem onClick={() => window.location.href = getDashboardUrl(userRole)}>
|
||||||
<Home className="w-4 h-4 mr-2" />
|
<Home className="w-4 h-4 mr-2" />Dashboard
|
||||||
Dashboard
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("WorkforceProfile")}>
|
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("WorkforceProfile")}>
|
||||||
<User className="w-4 h-4 mr-2" />
|
<User className="w-4 h-4 mr-2" />My Profile
|
||||||
My Profile
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -686,15 +492,11 @@ export default function Layout({ children }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NotificationPanel
|
<NotificationPanel isOpen={showNotifications} onClose={() => setShowNotifications(false)} />
|
||||||
isOpen={showNotifications}
|
<NotificationEngine />
|
||||||
onClose={() => setShowNotifications(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ChatBubble />
|
<ChatBubble />
|
||||||
<RoleSwitcher />
|
<RoleSwitcher />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
271
frontend-web/src/pages/NotificationSettings.jsx
Normal file
271
frontend-web/src/pages/NotificationSettings.jsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Bell, Mail, Calendar, Briefcase, AlertCircle, CheckCircle } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
export default function NotificationSettings() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: currentUser } = useQuery({
|
||||||
|
queryKey: ['current-user-notification-settings'],
|
||||||
|
queryFn: () => base44.auth.me(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [preferences, setPreferences] = useState(
|
||||||
|
currentUser?.notification_preferences || {
|
||||||
|
email_notifications: true,
|
||||||
|
in_app_notifications: true,
|
||||||
|
shift_assignments: true,
|
||||||
|
shift_reminders: true,
|
||||||
|
shift_changes: true,
|
||||||
|
upcoming_events: true,
|
||||||
|
new_leads: true,
|
||||||
|
invoice_updates: true,
|
||||||
|
system_alerts: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatePreferencesMutation = useMutation({
|
||||||
|
mutationFn: (prefs) => base44.auth.updateMe({ notification_preferences: prefs }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user-notification-settings'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Settings Updated",
|
||||||
|
description: "Your notification preferences have been saved",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "❌ Update Failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggle = (key) => {
|
||||||
|
setPreferences(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
updatePreferencesMutation.mutate(preferences);
|
||||||
|
};
|
||||||
|
|
||||||
|
const userRole = currentUser?.role || currentUser?.user_role || 'admin';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Notification Settings</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
|
Configure how and when you receive notifications
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Global Settings */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
Global Notification Settings
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Bell className="w-5 h-5 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">In-App Notifications</Label>
|
||||||
|
<p className="text-sm text-slate-500">Receive notifications in the app</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.in_app_notifications}
|
||||||
|
onCheckedChange={() => handleToggle('in_app_notifications')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Mail className="w-5 h-5 text-purple-600" />
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">Email Notifications</Label>
|
||||||
|
<p className="text-sm text-slate-500">Receive notifications via email</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.email_notifications}
|
||||||
|
onCheckedChange={() => handleToggle('email_notifications')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Staff/Workforce Notifications */}
|
||||||
|
{(userRole === 'workforce' || userRole === 'admin' || userRole === 'vendor') && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-5 h-5" />
|
||||||
|
Shift Notifications
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">Shift Assignments</Label>
|
||||||
|
<p className="text-sm text-slate-500">When you're assigned to a new shift</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.shift_assignments}
|
||||||
|
onCheckedChange={() => handleToggle('shift_assignments')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">Shift Reminders</Label>
|
||||||
|
<p className="text-sm text-slate-500">24 hours before your shift starts</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.shift_reminders}
|
||||||
|
onCheckedChange={() => handleToggle('shift_reminders')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">Shift Changes</Label>
|
||||||
|
<p className="text-sm text-slate-500">When shift details are modified</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.shift_changes}
|
||||||
|
onCheckedChange={() => handleToggle('shift_changes')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Client Notifications */}
|
||||||
|
{(userRole === 'client' || userRole === 'admin') && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Briefcase className="w-5 h-5" />
|
||||||
|
Event Notifications
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">Upcoming Events</Label>
|
||||||
|
<p className="text-sm text-slate-500">Reminders 3 days before your event</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.upcoming_events}
|
||||||
|
onCheckedChange={() => handleToggle('upcoming_events')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">Staff Updates</Label>
|
||||||
|
<p className="text-sm text-slate-500">When staff are assigned or changed</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.shift_changes}
|
||||||
|
onCheckedChange={() => handleToggle('shift_changes')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vendor Notifications */}
|
||||||
|
{(userRole === 'vendor' || userRole === 'admin') && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Briefcase className="w-5 h-5" />
|
||||||
|
Business Notifications
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">New Leads</Label>
|
||||||
|
<p className="text-sm text-slate-500">When new staffing opportunities are available</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.new_leads}
|
||||||
|
onCheckedChange={() => handleToggle('new_leads')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">Invoice Updates</Label>
|
||||||
|
<p className="text-sm text-slate-500">Invoice status changes and payments</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.invoice_updates}
|
||||||
|
onCheckedChange={() => handleToggle('invoice_updates')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* System Notifications */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
System Notifications
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">System Alerts</Label>
|
||||||
|
<p className="text-sm text-slate-500">Important platform updates and announcements</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.system_alerts}
|
||||||
|
onCheckedChange={() => handleToggle('system_alerts')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPreferences(currentUser?.notification_preferences || {})}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updatePreferencesMutation.isPending}
|
||||||
|
className="bg-[#0A39DF]"
|
||||||
|
>
|
||||||
|
{updatePreferencesMutation.isPending ? "Saving..." : "Save Preferences"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
505
frontend-web/src/pages/RapidOrder.jsx
Normal file
505
frontend-web/src/pages/RapidOrder.jsx
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { createPageUrl } from "@/utils";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Zap, Send, Check, Edit3, MapPin, Clock, Users, AlertCircle, Sparkles, Mic, X, Calendar as CalendarIcon, ArrowLeft } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
// Helper function to convert 24-hour time to 12-hour format
|
||||||
|
const convertTo12Hour = (time24) => {
|
||||||
|
if (!time24 || time24 === "—") return time24;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = time24.split(':');
|
||||||
|
if (!parts || parts.length < 2) return time24;
|
||||||
|
|
||||||
|
const hours = parseInt(parts[0], 10);
|
||||||
|
const minutes = parseInt(parts[1], 10);
|
||||||
|
|
||||||
|
if (isNaN(hours) || isNaN(minutes)) return time24;
|
||||||
|
|
||||||
|
const period = hours >= 12 ? 'PM' : 'AM';
|
||||||
|
const hours12 = hours % 12 || 12;
|
||||||
|
const minutesStr = minutes.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
return `${hours12}:${minutesStr} ${period}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error converting time:', error);
|
||||||
|
return time24;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RapidOrder() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [conversation, setConversation] = useState([]);
|
||||||
|
const [detectedOrder, setDetectedOrder] = useState(null);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [isListening, setIsListening] = useState(false);
|
||||||
|
const [submissionTime, setSubmissionTime] = useState(null);
|
||||||
|
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ['current-user-rapid'],
|
||||||
|
queryFn: () => base44.auth.me(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: businesses } = useQuery({
|
||||||
|
queryKey: ['user-businesses'],
|
||||||
|
queryFn: () => base44.entities.Business.filter({ contact_name: user?.full_name }),
|
||||||
|
enabled: !!user,
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRapidOrderMutation = useMutation({
|
||||||
|
mutationFn: (orderData) => base44.entities.Event.create(orderData),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['client-events'] });
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
setSubmissionTime(now);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "✅ RAPID Order Created",
|
||||||
|
description: "Order sent to preferred vendor with priority notification",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show success message in chat
|
||||||
|
setConversation(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: `🚀 **Order Submitted Successfully!**\n\nOrder Number: **${data.id?.slice(-8) || 'RAPID-001'}**\nSubmitted: **${format(now, 'h:mm:ss a')}**\n\nYour preferred vendor has been notified and will assign staff shortly.`,
|
||||||
|
isSuccess: true
|
||||||
|
}]);
|
||||||
|
|
||||||
|
// Reset after delay
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(createPageUrl("ClientDashboard"));
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const analyzeMessage = async (msg) => {
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
setConversation(prev => [...prev, { role: 'user', content: msg }]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await base44.integrations.Core.InvokeLLM({
|
||||||
|
prompt: `You are an order assistant. Analyze this message and extract order details:
|
||||||
|
|
||||||
|
Message: "${msg}"
|
||||||
|
Current user: ${user?.full_name}
|
||||||
|
User's locations: ${businesses.map(b => b.business_name).join(', ')}
|
||||||
|
|
||||||
|
Extract:
|
||||||
|
1. Urgency keywords (ASAP, today, emergency, call out, urgent, rapid, now)
|
||||||
|
2. Role/position needed (cook, bartender, server, dishwasher, etc.)
|
||||||
|
3. Number of staff (if mentioned, parse the number correctly - e.g., "5 cooks" = 5, "need 3 servers" = 3)
|
||||||
|
4. End time (if mentioned, extract the time - e.g., "until 5am" = "05:00", "until 11pm" = "23:00", "until midnight" = "00:00")
|
||||||
|
5. Location (if mentioned, otherwise use first available location)
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Make sure to correctly extract the number of staff from phrases like "need 5 cooks" or "I need 3 bartenders"
|
||||||
|
- If end time is mentioned (e.g., "until 5am", "till 11pm"), extract it in 24-hour format (e.g., "05:00", "23:00")
|
||||||
|
- If no end time is mentioned, leave it as null
|
||||||
|
|
||||||
|
Return a concise summary.`,
|
||||||
|
response_json_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
is_urgent: { type: "boolean" },
|
||||||
|
role: { type: "string" },
|
||||||
|
count: { type: "number" },
|
||||||
|
location: { type: "string" },
|
||||||
|
end_time: { type: "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = response;
|
||||||
|
const primaryLocation = businesses[0]?.business_name || "Primary Location";
|
||||||
|
|
||||||
|
// Ensure count is properly set - default to 1 if not detected
|
||||||
|
const staffCount = parsed.count && parsed.count > 0 ? parsed.count : 1;
|
||||||
|
|
||||||
|
// Get current time for start_time (when ASAP)
|
||||||
|
const now = new Date();
|
||||||
|
const currentTime = format(now, 'HH:mm');
|
||||||
|
|
||||||
|
// Handle end_time - use parsed end time or current time as confirmation time
|
||||||
|
const endTime = parsed.end_time || currentTime;
|
||||||
|
|
||||||
|
const order = {
|
||||||
|
is_rapid: parsed.is_urgent || true,
|
||||||
|
role: parsed.role || "Staff Member",
|
||||||
|
count: staffCount,
|
||||||
|
location: parsed.location || primaryLocation,
|
||||||
|
start_time: currentTime, // Always use current time for ASAP orders (24-hour format for storage)
|
||||||
|
end_time: endTime, // Use parsed end time or current time (24-hour format for storage)
|
||||||
|
start_time_display: convertTo12Hour(currentTime), // For display
|
||||||
|
end_time_display: convertTo12Hour(endTime), // For display
|
||||||
|
business_name: primaryLocation,
|
||||||
|
hub: businesses[0]?.hub_building || "Main Hub",
|
||||||
|
submission_time: now // Store the actual submission time
|
||||||
|
};
|
||||||
|
|
||||||
|
setDetectedOrder(order);
|
||||||
|
|
||||||
|
const aiMessage = `Is this a RAPID ORDER for **${order.count} ${order.role}${order.count > 1 ? 's' : ''}** at **${order.location}**?\n\nStart Time: ${order.start_time_display}\nEnd Time: ${order.end_time_display}`;
|
||||||
|
|
||||||
|
setConversation(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: aiMessage,
|
||||||
|
showConfirm: true
|
||||||
|
}]);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
setConversation(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: "I couldn't process that. Please provide more details like: role needed, how many, and when."
|
||||||
|
}]);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = () => {
|
||||||
|
if (!message.trim()) return;
|
||||||
|
analyzeMessage(message);
|
||||||
|
setMessage("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVoiceInput = () => {
|
||||||
|
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
|
||||||
|
toast({
|
||||||
|
title: "Voice not supported",
|
||||||
|
description: "Your browser doesn't support voice input",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
const recognition = new SpeechRecognition();
|
||||||
|
|
||||||
|
recognition.onstart = () => setIsListening(true);
|
||||||
|
recognition.onend = () => setIsListening(false);
|
||||||
|
|
||||||
|
recognition.onresult = (event) => {
|
||||||
|
const transcript = event.results[0][0].transcript;
|
||||||
|
setMessage(transcript);
|
||||||
|
analyzeMessage(transcript);
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = () => {
|
||||||
|
setIsListening(false);
|
||||||
|
toast({
|
||||||
|
title: "Voice input failed",
|
||||||
|
description: "Please try typing instead",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmOrder = () => {
|
||||||
|
if (!detectedOrder) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const confirmTime = format(now, 'HH:mm');
|
||||||
|
const confirmTime12Hour = convertTo12Hour(confirmTime);
|
||||||
|
|
||||||
|
// Create comprehensive order data with proper requested field and actual times
|
||||||
|
const orderData = {
|
||||||
|
event_name: `RAPID: ${detectedOrder.count} ${detectedOrder.role}${detectedOrder.count > 1 ? 's' : ''}`,
|
||||||
|
is_rapid: true,
|
||||||
|
status: "Pending",
|
||||||
|
business_name: detectedOrder.business_name,
|
||||||
|
hub: detectedOrder.hub,
|
||||||
|
event_location: detectedOrder.location,
|
||||||
|
date: now.toISOString().split('T')[0],
|
||||||
|
requested: Number(detectedOrder.count), // Ensure it's a number
|
||||||
|
client_name: user?.full_name,
|
||||||
|
client_email: user?.email,
|
||||||
|
notes: `RAPID ORDER - Submitted at ${detectedOrder.start_time_display} - Confirmed at ${confirmTime12Hour}\nStart: ${detectedOrder.start_time_display} | End: ${detectedOrder.end_time_display}`,
|
||||||
|
shifts: [{
|
||||||
|
shift_name: "Emergency Shift",
|
||||||
|
location: detectedOrder.location,
|
||||||
|
roles: [{
|
||||||
|
role: detectedOrder.role,
|
||||||
|
count: Number(detectedOrder.count), // Ensure it's a number
|
||||||
|
start_time: detectedOrder.start_time, // Store in 24-hour format
|
||||||
|
end_time: detectedOrder.end_time // Store in 24-hour format
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Creating RAPID order with data:', orderData); // Debug log
|
||||||
|
|
||||||
|
createRapidOrderMutation.mutate(orderData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditOrder = () => {
|
||||||
|
setConversation(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: "Please describe what you'd like to change."
|
||||||
|
}]);
|
||||||
|
setDetectedOrder(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-red-50 via-orange-50 to-yellow-50 p-6">
|
||||||
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(createPageUrl("ClientDashboard"))}
|
||||||
|
className="hover:bg-white/50"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-orange-500 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<Zap className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-red-700 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-6 h-6" />
|
||||||
|
RAPID Order
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-red-600 mt-1">Emergency staffing in minutes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600 mb-1">
|
||||||
|
<CalendarIcon className="w-4 h-4" />
|
||||||
|
<span>{format(new Date(), 'EEEE, MMMM d, yyyy')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>{format(new Date(), 'h:mm a')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="bg-white border-2 border-red-300 shadow-2xl">
|
||||||
|
<CardHeader className="border-b border-red-200 bg-gradient-to-r from-red-50 to-orange-50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg font-bold text-red-700">
|
||||||
|
Tell us what you need
|
||||||
|
</CardTitle>
|
||||||
|
<Badge className="bg-red-600 text-white font-bold text-sm px-4 py-2 shadow-md animate-pulse">
|
||||||
|
URGENT
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-6">
|
||||||
|
{/* Chat Messages */}
|
||||||
|
<div className="space-y-4 mb-6 max-h-[500px] overflow-y-auto">
|
||||||
|
{conversation.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-red-500 to-orange-500 rounded-2xl flex items-center justify-center shadow-2xl">
|
||||||
|
<Zap className="w-10 h-10 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-2xl text-slate-900 mb-3">Need staff urgently?</h3>
|
||||||
|
<p className="text-base text-slate-600 mb-6">Type or speak what you need, I'll handle the rest</p>
|
||||||
|
<div className="text-left max-w-lg mx-auto space-y-3">
|
||||||
|
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-4 rounded-xl border-2 border-blue-200 text-sm">
|
||||||
|
<strong className="text-blue-900">Example:</strong> <span className="text-slate-700">"We had a call out. Need 2 cooks ASAP"</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gradient-to-r from-purple-50 to-pink-50 p-4 rounded-xl border-2 border-purple-200 text-sm">
|
||||||
|
<strong className="text-purple-900">Example:</strong> <span className="text-slate-700">"Need 5 bartenders ASAP until 5am"</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gradient-to-r from-green-50 to-emerald-50 p-4 rounded-xl border-2 border-green-200 text-sm">
|
||||||
|
<strong className="text-green-900">Example:</strong> <span className="text-slate-700">"Emergency! Need 3 servers right now till midnight"</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{conversation.map((msg, idx) => (
|
||||||
|
<motion.div
|
||||||
|
key={idx}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<div className={`max-w-[85%] ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-gradient-to-br from-blue-600 to-blue-700 text-white'
|
||||||
|
: msg.isSuccess
|
||||||
|
? 'bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300'
|
||||||
|
: 'bg-white border-2 border-red-200'
|
||||||
|
} rounded-2xl p-5 shadow-lg`}>
|
||||||
|
{msg.role === 'assistant' && !msg.isSuccess && (
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="w-7 h-7 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center">
|
||||||
|
<Sparkles className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-red-600">AI Assistant</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className={`text-base whitespace-pre-line ${
|
||||||
|
msg.role === 'user' ? 'text-white' :
|
||||||
|
msg.isSuccess ? 'text-green-900' :
|
||||||
|
'text-slate-900'
|
||||||
|
}`}>
|
||||||
|
{msg.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{msg.showConfirm && detectedOrder && (
|
||||||
|
<div className="mt-5 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 p-4 bg-gradient-to-br from-slate-50 to-blue-50 rounded-xl border-2 border-blue-300">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||||
|
<Users className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 font-semibold">Staff Needed</p>
|
||||||
|
<p className="font-bold text-base text-slate-900">{detectedOrder.count} {detectedOrder.role}{detectedOrder.count > 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||||
|
<MapPin className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 font-semibold">Location</p>
|
||||||
|
<p className="font-bold text-base text-slate-900">{detectedOrder.location}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 col-span-2">
|
||||||
|
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||||
|
<Clock className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 font-semibold">Time</p>
|
||||||
|
<p className="font-bold text-base text-slate-900">
|
||||||
|
Start: {detectedOrder.start_time_display} | End: {detectedOrder.end_time_display}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmOrder}
|
||||||
|
disabled={createRapidOrderMutation.isPending}
|
||||||
|
className="flex-1 bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-xl text-base py-6"
|
||||||
|
>
|
||||||
|
<Check className="w-5 h-5 mr-2" />
|
||||||
|
{createRapidOrderMutation.isPending ? "Creating..." : "CONFIRM & SEND"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleEditOrder}
|
||||||
|
variant="outline"
|
||||||
|
className="border-2 border-red-300 hover:bg-red-50 text-base py-6"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-5 h-5 mr-2" />
|
||||||
|
EDIT
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{isProcessing && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="flex justify-start"
|
||||||
|
>
|
||||||
|
<div className="bg-white border-2 border-red-200 rounded-2xl p-5 shadow-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-7 h-7 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center animate-pulse">
|
||||||
|
<Sparkles className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-base text-slate-600">Processing your request...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Type or speak... (e.g., 'Need 5 cooks ASAP until 5am')"
|
||||||
|
className="flex-1 border-2 border-red-300 focus:border-red-500 text-base resize-none"
|
||||||
|
rows={3}
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleVoiceInput}
|
||||||
|
disabled={isProcessing || isListening}
|
||||||
|
variant="outline"
|
||||||
|
className={`border-2 ${isListening ? 'border-red-500 bg-red-50' : 'border-red-300'} hover:bg-red-50 text-base py-6 px-6`}
|
||||||
|
>
|
||||||
|
<Mic className={`w-5 h-5 mr-2 ${isListening ? 'animate-pulse text-red-600' : ''}`} />
|
||||||
|
{isListening ? 'Listening...' : 'Speak'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={!message.trim() || isProcessing}
|
||||||
|
className="flex-1 bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-xl text-base py-6"
|
||||||
|
>
|
||||||
|
<Send className="w-5 h-5 mr-2" />
|
||||||
|
Send Message
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Helper Text */}
|
||||||
|
<div className="mt-4 p-4 bg-blue-50 border-2 border-blue-200 rounded-xl">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<strong>Tip:</strong> Include role, quantity, and urgency for fastest processing.
|
||||||
|
Optionally add end time like "until 5am" or "till midnight".
|
||||||
|
AI will auto-detect your location and send to your preferred vendor with priority notification.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
36
frontend-web/src/pages/SmartScheduler.jsx
Normal file
36
frontend-web/src/pages/SmartScheduler.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { createPageUrl } from "@/utils";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowRight, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
export default function SmartScheduler() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-8 bg-slate-50 min-h-screen flex items-center justify-center">
|
||||||
|
<Card className="max-w-2xl w-full">
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<Sparkles className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 mb-4">
|
||||||
|
Smart Scheduling is Now Part of Orders
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-slate-600 mb-8">
|
||||||
|
All smart assignment, automation, and scheduling features have been unified into the main Order Management view for a consistent experience.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={() => navigate(createPageUrl("Events"))}
|
||||||
|
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
|
||||||
|
>
|
||||||
|
Go to Order Management
|
||||||
|
<ArrowRight className="w-5 h-5 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
frontend-web/src/pages/StaffOnboarding.jsx
Normal file
197
frontend-web/src/pages/StaffOnboarding.jsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { createPageUrl } from "@/utils";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CheckCircle, Circle } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import ProfileSetupStep from "@/components/onboarding/ProfileSetupStep";
|
||||||
|
import DocumentUploadStep from "@/components/onboarding/DocumentUploadStep";
|
||||||
|
import TrainingStep from "@/components/onboarding/TrainingStep";
|
||||||
|
import CompletionStep from "@/components/onboarding/CompletionStep";
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ id: 1, name: "Profile Setup", description: "Basic information" },
|
||||||
|
{ id: 2, name: "Documents", description: "Upload required documents" },
|
||||||
|
{ id: 3, name: "Training", description: "Complete compliance training" },
|
||||||
|
{ id: 4, name: "Complete", description: "Finish onboarding" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function StaffOnboarding() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [onboardingData, setOnboardingData] = useState({
|
||||||
|
profile: {},
|
||||||
|
documents: [],
|
||||||
|
training: { completed: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: currentUser } = useQuery({
|
||||||
|
queryKey: ['current-user-onboarding'],
|
||||||
|
queryFn: () => base44.auth.me(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStaffMutation = useMutation({
|
||||||
|
mutationFn: (staffData) => base44.entities.Staff.create(staffData),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Onboarding Complete",
|
||||||
|
description: "Welcome to KROW! Your profile is now active.",
|
||||||
|
});
|
||||||
|
navigate(createPageUrl("WorkforceDashboard"));
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "❌ Onboarding Failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleNext = (stepData) => {
|
||||||
|
setOnboardingData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[stepData.type]: stepData.data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (currentStep < steps.length) {
|
||||||
|
setCurrentStep(currentStep + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (currentStep > 1) {
|
||||||
|
setCurrentStep(currentStep - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = () => {
|
||||||
|
const staffData = {
|
||||||
|
employee_name: onboardingData.profile.full_name,
|
||||||
|
email: onboardingData.profile.email || currentUser?.email,
|
||||||
|
phone: onboardingData.profile.phone,
|
||||||
|
address: onboardingData.profile.address,
|
||||||
|
city: onboardingData.profile.city,
|
||||||
|
position: onboardingData.profile.position,
|
||||||
|
department: onboardingData.profile.department,
|
||||||
|
hub_location: onboardingData.profile.hub_location,
|
||||||
|
employment_type: onboardingData.profile.employment_type,
|
||||||
|
english: onboardingData.profile.english_level,
|
||||||
|
certifications: onboardingData.documents.filter(d => d.type === 'certification').map(d => ({
|
||||||
|
name: d.name,
|
||||||
|
issued_date: d.issued_date,
|
||||||
|
expiry_date: d.expiry_date,
|
||||||
|
document_url: d.url,
|
||||||
|
})),
|
||||||
|
background_check_status: onboardingData.documents.some(d => d.type === 'background_check') ? 'pending' : 'not_required',
|
||||||
|
notes: `Onboarding completed: ${new Date().toISOString()}. Training modules completed: ${onboardingData.training.completed.length}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
createStaffMutation.mutate(staffData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStep = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<ProfileSetupStep
|
||||||
|
data={onboardingData.profile}
|
||||||
|
onNext={handleNext}
|
||||||
|
currentUser={currentUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<DocumentUploadStep
|
||||||
|
data={onboardingData.documents}
|
||||||
|
onNext={handleNext}
|
||||||
|
onBack={handleBack}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<TrainingStep
|
||||||
|
data={onboardingData.training}
|
||||||
|
onNext={handleNext}
|
||||||
|
onBack={handleBack}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 4:
|
||||||
|
return (
|
||||||
|
<CompletionStep
|
||||||
|
data={onboardingData}
|
||||||
|
onComplete={handleComplete}
|
||||||
|
onBack={handleBack}
|
||||||
|
isSubmitting={createStaffMutation.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 p-4 md:p-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 mb-2">
|
||||||
|
Welcome to KROW! 👋
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Let's get you set up in just a few steps
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Steps */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{steps.map((step, idx) => (
|
||||||
|
<React.Fragment key={step.id}>
|
||||||
|
<div className="flex flex-col items-center flex-1">
|
||||||
|
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
|
||||||
|
currentStep > step.id
|
||||||
|
? "bg-green-500 text-white"
|
||||||
|
: currentStep === step.id
|
||||||
|
? "bg-[#0A39DF] text-white"
|
||||||
|
: "bg-slate-200 text-slate-400"
|
||||||
|
}`}>
|
||||||
|
{currentStep > step.id ? (
|
||||||
|
<CheckCircle className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<span className="font-bold">{step.id}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={`text-sm font-medium mt-2 ${
|
||||||
|
currentStep >= step.id ? "text-slate-900" : "text-slate-400"
|
||||||
|
}`}>
|
||||||
|
{step.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">{step.description}</p>
|
||||||
|
</div>
|
||||||
|
{idx < steps.length - 1 && (
|
||||||
|
<div className={`flex-1 h-1 ${
|
||||||
|
currentStep > step.id ? "bg-green-500" : "bg-slate-200"
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Content */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 md:p-8">
|
||||||
|
{renderStep()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
466
frontend-web/src/pages/TaskBoard.jsx
Normal file
466
frontend-web/src/pages/TaskBoard.jsx
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { DragDropContext, Draggable } from "@hello-pangea/dnd";
|
||||||
|
import { Link2, Plus, Users } from "lucide-react";
|
||||||
|
import TaskCard from "@/components/tasks/TaskCard";
|
||||||
|
import TaskColumn from "@/components/tasks/TaskColumn";
|
||||||
|
import TaskDetailModal from "@/components/tasks/TaskDetailModal";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
export default function TaskBoard() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [createDialog, setCreateDialog] = useState(false);
|
||||||
|
const [selectedTask, setSelectedTask] = useState(null);
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState("pending");
|
||||||
|
const [newTask, setNewTask] = useState({
|
||||||
|
task_name: "",
|
||||||
|
description: "",
|
||||||
|
priority: "normal",
|
||||||
|
due_date: "",
|
||||||
|
progress: 0,
|
||||||
|
assigned_members: []
|
||||||
|
});
|
||||||
|
const [selectedMembers, setSelectedMembers] = useState([]);
|
||||||
|
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ['current-user-taskboard'],
|
||||||
|
queryFn: () => base44.auth.me(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: teams = [] } = useQuery({
|
||||||
|
queryKey: ['teams'],
|
||||||
|
queryFn: () => base44.entities.Team.list(),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: teamMembers = [] } = useQuery({
|
||||||
|
queryKey: ['team-members'],
|
||||||
|
queryFn: () => base44.entities.TeamMember.list(),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tasks = [] } = useQuery({
|
||||||
|
queryKey: ['tasks'],
|
||||||
|
queryFn: () => base44.entities.Task.list(),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const userTeam = teams.find(t => t.owner_id === user?.id) || teams[0];
|
||||||
|
const teamTasks = tasks.filter(t => t.team_id === userTeam?.id);
|
||||||
|
const currentTeamMembers = teamMembers.filter(m => m.team_id === userTeam?.id);
|
||||||
|
|
||||||
|
const leadMembers = currentTeamMembers.filter(m => m.role === 'admin' || m.role === 'manager');
|
||||||
|
const regularMembers = currentTeamMembers.filter(m => m.role === 'member');
|
||||||
|
|
||||||
|
// Get unique departments from team members
|
||||||
|
const departments = [...new Set(currentTeamMembers.map(m => m.department).filter(Boolean))];
|
||||||
|
|
||||||
|
const tasksByStatus = useMemo(() => ({
|
||||||
|
pending: teamTasks.filter(t => t.status === 'pending').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
|
||||||
|
in_progress: teamTasks.filter(t => t.status === 'in_progress').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
|
||||||
|
on_hold: teamTasks.filter(t => t.status === 'on_hold').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
|
||||||
|
completed: teamTasks.filter(t => t.status === 'completed').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
|
||||||
|
}), [teamTasks]);
|
||||||
|
|
||||||
|
const overallProgress = useMemo(() => {
|
||||||
|
if (teamTasks.length === 0) return 0;
|
||||||
|
const totalProgress = teamTasks.reduce((sum, t) => sum + (t.progress || 0), 0);
|
||||||
|
return Math.round(totalProgress / teamTasks.length);
|
||||||
|
}, [teamTasks]);
|
||||||
|
|
||||||
|
const createTaskMutation = useMutation({
|
||||||
|
mutationFn: (taskData) => base44.entities.Task.create(taskData),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
setCreateDialog(false);
|
||||||
|
setNewTask({
|
||||||
|
task_name: "",
|
||||||
|
description: "",
|
||||||
|
priority: "normal",
|
||||||
|
due_date: "",
|
||||||
|
progress: 0,
|
||||||
|
assigned_members: []
|
||||||
|
});
|
||||||
|
setSelectedMembers([]);
|
||||||
|
toast({
|
||||||
|
title: "✅ Task Created",
|
||||||
|
description: "New task added to the board",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTaskMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }) => base44.entities.Task.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDragEnd = (result) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
|
||||||
|
const { source, destination, draggableId } = result;
|
||||||
|
|
||||||
|
if (source.droppableId === destination.droppableId && source.index === destination.index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = teamTasks.find(t => t.id === draggableId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const newStatus = destination.droppableId;
|
||||||
|
updateTaskMutation.mutate({
|
||||||
|
id: task.id,
|
||||||
|
data: {
|
||||||
|
...task,
|
||||||
|
status: newStatus,
|
||||||
|
order_index: destination.index
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTask = () => {
|
||||||
|
if (!newTask.task_name.trim()) {
|
||||||
|
toast({
|
||||||
|
title: "Task name required",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createTaskMutation.mutate({
|
||||||
|
...newTask,
|
||||||
|
team_id: userTeam?.id,
|
||||||
|
status: selectedStatus,
|
||||||
|
order_index: tasksByStatus[selectedStatus]?.length || 0,
|
||||||
|
assigned_members: selectedMembers.map(m => ({
|
||||||
|
member_id: m.id,
|
||||||
|
member_name: m.member_name,
|
||||||
|
avatar_url: m.avatar_url
|
||||||
|
})),
|
||||||
|
assigned_department: selectedMembers.length > 0 && selectedMembers[0].department ? selectedMembers[0].department : null
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 bg-slate-50 min-h-screen">
|
||||||
|
<div className="max-w-[1800px] mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white rounded-xl p-6 mb-6 shadow-sm border border-slate-200">
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 mb-2">Task Board</h1>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-slate-600">Lead</span>
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{leadMembers.slice(0, 3).map((member, idx) => (
|
||||||
|
<Avatar key={idx} className="w-8 h-8 border-2 border-white">
|
||||||
|
<img
|
||||||
|
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
|
||||||
|
alt={member.member_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
|
{leadMembers.length > 3 && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
|
||||||
|
+{leadMembers.length - 3}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-slate-600">Team</span>
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{regularMembers.slice(0, 3).map((member, idx) => (
|
||||||
|
<Avatar key={idx} className="w-8 h-8 border-2 border-white">
|
||||||
|
<img
|
||||||
|
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
|
||||||
|
alt={member.member_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
|
{regularMembers.length > 3 && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
|
||||||
|
+{regularMembers.length - 3}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" className="border-slate-300">
|
||||||
|
<Link2 className="w-4 h-4 mr-2" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedStatus("pending");
|
||||||
|
setCreateDialog(true);
|
||||||
|
}}
|
||||||
|
className="bg-[#0A39DF] hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overall Progress */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1 h-3 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-[#0A39DF] to-blue-600 transition-all"
|
||||||
|
style={{ width: `${overallProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-slate-900 ml-4">{overallProgress}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kanban Board */}
|
||||||
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
|
{['pending', 'in_progress', 'on_hold', 'completed'].map((status) => (
|
||||||
|
<TaskColumn
|
||||||
|
key={status}
|
||||||
|
status={status}
|
||||||
|
tasks={tasksByStatus[status]}
|
||||||
|
onAddTask={(status) => {
|
||||||
|
setSelectedStatus(status);
|
||||||
|
setCreateDialog(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tasksByStatus[status].map((task, index) => (
|
||||||
|
<Draggable key={task.id} draggableId={task.id} index={index}>
|
||||||
|
{(provided) => (
|
||||||
|
<TaskCard
|
||||||
|
task={task}
|
||||||
|
provided={provided}
|
||||||
|
onClick={() => setSelectedTask(task)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
</TaskColumn>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DragDropContext>
|
||||||
|
|
||||||
|
{teamTasks.length === 0 && (
|
||||||
|
<div className="text-center py-16 bg-white rounded-xl border-2 border-dashed border-slate-300">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-slate-100 rounded-xl flex items-center justify-center">
|
||||||
|
<Plus className="w-8 h-8 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-xl text-slate-900 mb-2">No tasks yet</h3>
|
||||||
|
<p className="text-slate-600 mb-5">Create your first task to get started</p>
|
||||||
|
<Button onClick={() => setCreateDialog(true)} className="bg-[#0A39DF]">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create Task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Task Dialog */}
|
||||||
|
<Dialog open={createDialog} onOpenChange={setCreateDialog}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Task</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div>
|
||||||
|
<Label>Task Name *</Label>
|
||||||
|
<Input
|
||||||
|
value={newTask.task_name}
|
||||||
|
onChange={(e) => setNewTask({ ...newTask, task_name: e.target.value })}
|
||||||
|
placeholder="e.g., Website Design"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea
|
||||||
|
value={newTask.description}
|
||||||
|
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
||||||
|
placeholder="Task details..."
|
||||||
|
rows={3}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Priority</Label>
|
||||||
|
<Select value={newTask.priority} onValueChange={(val) => setNewTask({ ...newTask, priority: val })}>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
<SelectItem value="normal">Normal</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Due Date</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={newTask.due_date}
|
||||||
|
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Initial Progress (%)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={newTask.progress}
|
||||||
|
onChange={(e) => setNewTask({ ...newTask, progress: parseInt(e.target.value) || 0 })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<Label>Assign Team Members</Label>
|
||||||
|
{departments.length > 0 && (
|
||||||
|
<Select onValueChange={(dept) => {
|
||||||
|
const deptMembers = currentTeamMembers.filter(m => m.department === dept);
|
||||||
|
setSelectedMembers(deptMembers);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="w-56">
|
||||||
|
<SelectValue placeholder="Assign entire department" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{departments.map((dept) => {
|
||||||
|
const count = currentTeamMembers.filter(m => m.department === dept).length;
|
||||||
|
return (
|
||||||
|
<SelectItem key={dept} value={dept}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{dept} ({count} members)
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{currentTeamMembers.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">No team members available</p>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-48 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
|
||||||
|
{currentTeamMembers.map((member) => {
|
||||||
|
const isSelected = selectedMembers.some(m => m.id === member.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
setSelectedMembers(selectedMembers.filter(m => m.id !== member.id));
|
||||||
|
} else {
|
||||||
|
setSelectedMembers([...selectedMembers, member]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-all ${
|
||||||
|
isSelected ? 'bg-blue-50 border-2 border-[#0A39DF]' : 'hover:bg-slate-50 border-2 border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="w-4 h-4 rounded text-[#0A39DF] focus:ring-[#0A39DF]"
|
||||||
|
/>
|
||||||
|
<Avatar className="w-8 h-8">
|
||||||
|
<img
|
||||||
|
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
|
||||||
|
alt={member.member_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-slate-900">{member.member_name}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{member.department ? `${member.department} • ` : ''}{member.role || 'Member'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedMembers.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-slate-50 rounded-lg">
|
||||||
|
{selectedMembers.map((member) => (
|
||||||
|
<Badge key={member.id} className="bg-[#0A39DF] text-white flex items-center gap-1">
|
||||||
|
{member.member_name}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedMembers(selectedMembers.filter(m => m.id !== member.id));
|
||||||
|
}}
|
||||||
|
className="ml-1 hover:bg-white/20 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateTask}
|
||||||
|
disabled={createTaskMutation.isPending}
|
||||||
|
className="bg-[#0A39DF]"
|
||||||
|
>
|
||||||
|
Create Task
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Task Detail Modal with Comments */}
|
||||||
|
<TaskDetailModal
|
||||||
|
task={selectedTask}
|
||||||
|
open={!!selectedTask}
|
||||||
|
onClose={() => setSelectedTask(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,16 @@ import VendorDocumentReview from "./VendorDocumentReview";
|
|||||||
|
|
||||||
import VendorMarketplace from "./VendorMarketplace";
|
import VendorMarketplace from "./VendorMarketplace";
|
||||||
|
|
||||||
|
import RapidOrder from "./RapidOrder";
|
||||||
|
|
||||||
|
import SmartScheduler from "./SmartScheduler";
|
||||||
|
|
||||||
|
import StaffOnboarding from "./StaffOnboarding";
|
||||||
|
|
||||||
|
import NotificationSettings from "./NotificationSettings";
|
||||||
|
|
||||||
|
import TaskBoard from "./TaskBoard";
|
||||||
|
|
||||||
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
|
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
const PAGES = {
|
const PAGES = {
|
||||||
@@ -244,6 +254,16 @@ const PAGES = {
|
|||||||
|
|
||||||
VendorMarketplace: VendorMarketplace,
|
VendorMarketplace: VendorMarketplace,
|
||||||
|
|
||||||
|
RapidOrder: RapidOrder,
|
||||||
|
|
||||||
|
SmartScheduler: SmartScheduler,
|
||||||
|
|
||||||
|
StaffOnboarding: StaffOnboarding,
|
||||||
|
|
||||||
|
NotificationSettings: NotificationSettings,
|
||||||
|
|
||||||
|
TaskBoard: TaskBoard,
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getCurrentPage(url) {
|
function _getCurrentPage(url) {
|
||||||
@@ -391,6 +411,16 @@ function PagesContent() {
|
|||||||
|
|
||||||
<Route path="/VendorMarketplace" element={<VendorMarketplace />} />
|
<Route path="/VendorMarketplace" element={<VendorMarketplace />} />
|
||||||
|
|
||||||
|
<Route path="/RapidOrder" element={<RapidOrder />} />
|
||||||
|
|
||||||
|
<Route path="/SmartScheduler" element={<SmartScheduler />} />
|
||||||
|
|
||||||
|
<Route path="/StaffOnboarding" element={<StaffOnboarding />} />
|
||||||
|
|
||||||
|
<Route path="/NotificationSettings" element={<NotificationSettings />} />
|
||||||
|
|
||||||
|
<Route path="/TaskBoard" element={<TaskBoard />} />
|
||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
8
internal-api-harness/.env.example
Normal file
8
internal-api-harness/.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
VITE_HARNESS_FIREBASE_API_KEY="your-api-key"
|
||||||
|
VITE_HARNESS_FIREBASE_AUTH_DOMAIN="your-auth-domain"
|
||||||
|
VITE_HARNESS_FIREBASE_PROJECT_ID="your-project-id"
|
||||||
|
VITE_HARNESS_FIREBASE_STORAGE_BUCKET="your-storage-bucket"
|
||||||
|
VITE_HARNESS_FIREBASE_MESSAGING_SENDER_ID="your-messaging-sender-id"
|
||||||
|
VITE_HARNESS_FIREBASE_APP_ID="your-app-id"
|
||||||
|
VITE_HARNESS_ENVIRONMENT="dev"
|
||||||
|
VITE_API_BASE_URL="http://localhost:8080"
|
||||||
24
internal-api-harness/.gitignore
vendored
Normal file
24
internal-api-harness/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
16
internal-api-harness/README.md
Normal file
16
internal-api-harness/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
17
internal-api-harness/components.json
Normal file
17
internal-api-harness/components.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": false,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal-api-harness/eslint.config.js
Normal file
29
internal-api-harness/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
internal-api-harness/index.html
Normal file
13
internal-api-harness/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>internal-api-harness</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
internal-api-harness/jsconfig.json
Normal file
10
internal-api-harness/jsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6611
internal-api-harness/package-lock.json
generated
Normal file
6611
internal-api-harness/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
internal-api-harness/package.json
Normal file
45
internal-api-harness/package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "internal-api-harness",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"firebase": "^12.6.0",
|
||||||
|
"lucide-react": "^0.553.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-firebase-hooks": "^5.1.1",
|
||||||
|
"react-router-dom": "^7.9.6",
|
||||||
|
"shadcn-ui": "^0.9.5",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.2",
|
||||||
|
"@types/react-dom": "^19.2.2",
|
||||||
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
|
"autoprefixer": "^10.4.22",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.18",
|
||||||
|
"vite": "^7.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
internal-api-harness/postcss.config.js
Normal file
6
internal-api-harness/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
18
internal-api-harness/public/logo.svg
Normal file
18
internal-api-harness/public/logo.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="200 180 280 140" style="enable-background:new 0 0 500 500;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#24303B;}
|
||||||
|
.st1{fill:#002FE3;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M459.81,202.55c-5.03,0.59-9.08,4.49-10.36,9.38l-15.99,59.71l-16.24-56.3
|
||||||
|
c-1.68-5.92-6.22-10.86-12.19-12.34c-1.58-0.39-3.11-0.54-4.64-0.49h-0.15c-1.53-0.05-3.11,0.1-4.64,0.49
|
||||||
|
c-5.97,1.48-10.51,6.42-12.24,12.34l-3.6,12.53l-11.35,39.38l-7.9-27.54c-10.76-37.5-48.56-62.23-88.38-55.32
|
||||||
|
c-33.26,5.82-57.05,35.68-56.99,69.48v0.79c0,4.34,0.39,8.73,1.13,13.18c0.18,1.02,0.37,2.03,0.6,3.03
|
||||||
|
c1.84,8.31,10.93,12.73,18.49,8.8v0c5.36-2.79,7.84-8.89,6.42-14.77c-0.85-3.54-1.28-7.23-1.23-11.03
|
||||||
|
c0-25.02,20.48-45.5,45.55-45.2c7.6,0.1,15.59,2.07,23.59,6.37c13.52,7.3,23.15,20.18,27.34,34.94l13.32,46.34
|
||||||
|
c1.73,5.97,6.22,11,12.24,12.58c9.62,2.62,19-3.06,21.51-12.04l16.09-56.7l0.2-0.1l16.09,56.85c1.63,5.68,5.87,10.41,11.55,11.99
|
||||||
|
c9.13,2.57,18.11-2.66,20.67-11.2l24.13-79.6C475.35,209.85,468.64,201.56,459.81,202.55z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
internal-api-harness/public/vite.svg
Normal file
1
internal-api-harness/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
42
internal-api-harness/src/App.css
Normal file
42
internal-api-harness/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
45
internal-api-harness/src/App.jsx
Normal file
45
internal-api-harness/src/App.jsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
|
||||||
|
import Layout from "./components/Layout";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Home from "./pages/Home";
|
||||||
|
import GenerateImage from "./pages/GenerateImage";
|
||||||
|
import ApiPlaceholder from "./pages/ApiPlaceholder";
|
||||||
|
import GetMe from "./pages/auth/GetMe";
|
||||||
|
import SendEmail from "./pages/core/SendEmail";
|
||||||
|
import EntityTester from "./pages/EntityTester";
|
||||||
|
|
||||||
|
import InvokeLLM from "./pages/core/InvokeLLM";
|
||||||
|
import UploadFile from "./pages/core/UploadFile";
|
||||||
|
import UploadPrivateFile from "./pages/core/UploadPrivateFile";
|
||||||
|
import CreateSignedUrl from "./pages/core/CreateSignedUrl";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route index element={<Home />} />
|
||||||
|
|
||||||
|
{/* Auth APIs */}
|
||||||
|
<Route path="/auth/me" element={<GetMe />} />
|
||||||
|
<Route path="/auth/update-me" element={<ApiPlaceholder title="Update Me" />} />
|
||||||
|
|
||||||
|
{/* Core APIs */}
|
||||||
|
<Route path="/core/send-email" element={<SendEmail />} />
|
||||||
|
<Route path="/core/invoke-llm" element={<InvokeLLM />} />
|
||||||
|
<Route path="/core/upload-file" element={<UploadFile />} />
|
||||||
|
<Route path="/core/upload-private-file" element={<UploadPrivateFile />} />
|
||||||
|
<Route path="/core/create-signed-url" element={<CreateSignedUrl />} />
|
||||||
|
<Route path="/core/extract-data" element={<ApiPlaceholder title="Extract Data from File" />} />
|
||||||
|
<Route path="/core/generate-image" element={<GenerateImage />} />
|
||||||
|
|
||||||
|
{/* Entity APIs */}
|
||||||
|
<Route path="/entities" element={<EntityTester />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
22
internal-api-harness/src/api/client.js
Normal file
22
internal-api-harness/src/api/client.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { auth } from "../firebase";
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL, // You will need to add this to your .env file
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
async (config) => {
|
||||||
|
const user = auth.currentUser;
|
||||||
|
if (user) {
|
||||||
|
const token = await user.getIdToken();
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
147
internal-api-harness/src/api/krowSDK.js
Normal file
147
internal-api-harness/src/api/krowSDK.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
import { auth } from '../firebase';
|
||||||
|
import { signOut } from 'firebase/auth';
|
||||||
|
|
||||||
|
// --- Auth Module ---
|
||||||
|
const authModule = {
|
||||||
|
/**
|
||||||
|
* Fetches the currently authenticated user's profile from the backend.
|
||||||
|
* @returns {Promise<object>} The user profile.
|
||||||
|
*/
|
||||||
|
me: async () => {
|
||||||
|
const { data } = await apiClient.get('/auth/me');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the user out.
|
||||||
|
* @param {string} [redirectUrl] - Optional URL to redirect to after logout.
|
||||||
|
*/
|
||||||
|
logout: async (redirectUrl) => {
|
||||||
|
await signOut(auth);
|
||||||
|
if (redirectUrl) {
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a user is currently authenticated.
|
||||||
|
* @returns {boolean} True if a user is authenticated.
|
||||||
|
*/
|
||||||
|
isAuthenticated: () => {
|
||||||
|
return !!auth.currentUser;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Core Integrations Module ---
|
||||||
|
const coreIntegrationsModule = {
|
||||||
|
/**
|
||||||
|
* Sends an email.
|
||||||
|
* @param {object} params - { to, subject, body }
|
||||||
|
* @returns {Promise<object>} API response.
|
||||||
|
*/
|
||||||
|
SendEmail: async (params) => {
|
||||||
|
const { data } = await apiClient.post('/sendEmail', params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes a large language model.
|
||||||
|
* @param {object} params - { prompt, response_json_schema, file_urls }
|
||||||
|
* @returns {Promise<object>} API response.
|
||||||
|
*/
|
||||||
|
InvokeLLM: async (params) => {
|
||||||
|
const { data } = await apiClient.post('/invokeLLM', params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a public file.
|
||||||
|
* @param {File} file - The file to upload.
|
||||||
|
* @returns {Promise<object>} API response with file_url.
|
||||||
|
*/
|
||||||
|
UploadFile: async ({ file }) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const { data } = await apiClient.post('/uploadFile', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a private file.
|
||||||
|
* @param {File} file - The file to upload.
|
||||||
|
* @returns {Promise<object>} API response with file_uri.
|
||||||
|
*/
|
||||||
|
UploadPrivateFile: async ({ file }) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const { data } = await apiClient.post('/uploadPrivateFile', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a temporary signed URL for a private file.
|
||||||
|
* @param {object} params - { file_uri, expires_in }
|
||||||
|
* @returns {Promise<object>} API response with signed_url.
|
||||||
|
*/
|
||||||
|
CreateFileSignedUrl: async (params) => {
|
||||||
|
const { data } = await apiClient.post('/createSignedUrl', params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Entities Module ---
|
||||||
|
// Based on docs/07-reference-base44-api-export.md
|
||||||
|
const entityNames = [
|
||||||
|
"User", "Event", "Staff", "Vendor", "VendorRate", "Invoice", "Business",
|
||||||
|
"Certification", "Team", "Conversation", "Message", "ActivityLog",
|
||||||
|
"Enterprise", "Sector", "Partner", "Order", "Shift"
|
||||||
|
];
|
||||||
|
|
||||||
|
const entitiesModule = {};
|
||||||
|
|
||||||
|
entityNames.forEach(entityName => {
|
||||||
|
// This factory creates a standard set of CRUD-like methods for each entity.
|
||||||
|
// It assumes a consistent RESTful endpoint structure: /entities/{EntityName}/{method}
|
||||||
|
entitiesModule[entityName] = {
|
||||||
|
get: async (params) => {
|
||||||
|
const { data } = await apiClient.get(`/entities/${entityName}/get`, { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
create: async (params) => {
|
||||||
|
const { data } = await apiClient.post(`/entities/${entityName}/create`, params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
update: async (params) => {
|
||||||
|
const { data } = await apiClient.post(`/entities/${entityName}/update`, params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
delete: async (params) => {
|
||||||
|
const { data } = await apiClient.post(`/entities/${entityName}/delete`, params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
filter: async (params) => {
|
||||||
|
const { data } = await apiClient.post(`/entities/${entityName}/filter`, params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
list: async () => {
|
||||||
|
// Assuming a 'filter' with no params can act as 'list'
|
||||||
|
const { data } = await apiClient.post(`/entities/${entityName}/filter`, {});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- Main SDK Export ---
|
||||||
|
export const krowSDK = {
|
||||||
|
auth: authModule,
|
||||||
|
integrations: {
|
||||||
|
Core: coreIntegrationsModule,
|
||||||
|
},
|
||||||
|
entities: entitiesModule,
|
||||||
|
};
|
||||||
1
internal-api-harness/src/assets/react.svg
Normal file
1
internal-api-harness/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
38
internal-api-harness/src/components/ApiResponse.jsx
Normal file
38
internal-api-harness/src/components/ApiResponse.jsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const ApiResponse = ({ response, error, loading }) => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800 mb-2">Response</h3>
|
||||||
|
<div className="bg-slate-100 border border-slate-200 rounded-lg p-4">
|
||||||
|
<p className="text-slate-500">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-lg font-semibold text-red-800 mb-2">Error</h3>
|
||||||
|
<pre className="bg-red-50 border border-red-200 text-red-700 rounded-lg p-4 text-sm whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(error, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800 mb-2">Response</h3>
|
||||||
|
<pre className="bg-slate-100 border border-slate-200 rounded-lg p-4 text-sm whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(response, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiResponse;
|
||||||
125
internal-api-harness/src/components/Layout.jsx
Normal file
125
internal-api-harness/src/components/Layout.jsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { Link, Navigate, Outlet, useLocation } from "react-router-dom";
|
||||||
|
import { useAuthState } from "react-firebase-hooks/auth";
|
||||||
|
import { auth } from "../firebase";
|
||||||
|
import { krowSDK } from "@/api/krowSDK";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const navLinks = {
|
||||||
|
auth: [
|
||||||
|
{ path: "/auth/me", title: "Get Me" },
|
||||||
|
{ path: "/auth/update-me", title: "Update Me" },
|
||||||
|
],
|
||||||
|
core: [
|
||||||
|
{ path: "/core/send-email", title: "Send Email" },
|
||||||
|
{ path: "/core/invoke-llm", title: "Invoke LLM" },
|
||||||
|
{ path: "/core/upload-file", title: "Upload File" },
|
||||||
|
{ path: "/core/upload-private-file", title: "Upload Private File" },
|
||||||
|
{ path: "/core/create-signed-url", title: "Create Signed URL" },
|
||||||
|
{ path: "/core/extract-data", title: "Extract Data from File" },
|
||||||
|
{ path: "/core/generate-image", title: "Generate Image" },
|
||||||
|
],
|
||||||
|
entities: [
|
||||||
|
{ path: "/entities", title: "Entity API Tester" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const NavSection = ({ title, links }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
|
|
||||||
|
const navLinkClasses = (path) =>
|
||||||
|
`flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||||
|
location.pathname === path
|
||||||
|
? "bg-slate-200 text-slate-900"
|
||||||
|
: "text-slate-600 hover:bg-slate-100"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<CollapsibleTrigger className="w-full">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
<h2 className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<ChevronDown className={`w-4 h-4 text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="space-y-1">
|
||||||
|
{links.map((api) => (
|
||||||
|
<Link key={api.path} to={api.path} className={navLinkClasses(api.path)}>
|
||||||
|
{api.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const Layout = () => {
|
||||||
|
const [user, loading] = useAuthState(auth);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<div>Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
krowSDK.auth.logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-slate-50">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-72 bg-white border-r border-slate-200 flex flex-col">
|
||||||
|
<div className="p-6 border-b border-slate-200">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<img src="/logo.svg" alt="Krow Logo" className="h-12 w-12" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-900">KROW</h1>
|
||||||
|
<p className="text-xs text-slate-500">API Test Harness ({import.meta.env.VITE_HARNESS_ENVIRONMENT})</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
|
<NavSection title="Auth" links={navLinks.auth} />
|
||||||
|
<NavSection title="Core Integrations" links={navLinks.core} />
|
||||||
|
<NavSection title="Entities" links={navLinks.entities} />
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-slate-200">
|
||||||
|
<div className="text-sm text-slate-600 truncate mb-2" title={user.email}>{user.email}</div>
|
||||||
|
<Button onClick={handleLogout} className="w-full">
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<div className="p-8">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
120
internal-api-harness/src/components/ServiceTester.jsx
Normal file
120
internal-api-harness/src/components/ServiceTester.jsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import apiClient from "../api/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
const ServiceTester = ({ serviceName, serviceDescription, endpoint, fields }) => {
|
||||||
|
const [formData, setFormData] = useState({});
|
||||||
|
const [response, setResponse] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value, files } = e.target;
|
||||||
|
if (files) {
|
||||||
|
setFormData({ ...formData, [name]: files[0] });
|
||||||
|
} else {
|
||||||
|
setFormData({ ...formData, [name]: value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResponse(null);
|
||||||
|
|
||||||
|
const data = new FormData();
|
||||||
|
for (const key in formData) {
|
||||||
|
data.append(key, formData[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(endpoint, data);
|
||||||
|
setResponse(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data || err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">{serviceName}</h1>
|
||||||
|
<p className="text-slate-600 mt-1">{serviceDescription}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Parameters</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Fill in the required parameters to execute the service.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<div key={field.name} className="grid w-full max-w-sm items-center gap-1.5">
|
||||||
|
<Label htmlFor={field.name}>{field.label}</Label>
|
||||||
|
{field.type === "textarea" ? (
|
||||||
|
<Textarea
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
type={field.type}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Executing..." : "Execute"}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{response && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 mb-2">Response</h2>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<pre className="text-sm overflow-x-auto bg-slate-900 text-white p-4 rounded-md">
|
||||||
|
{JSON.stringify(response, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-xl font-bold text-red-600 mb-2">Error</h2>
|
||||||
|
<Card className="border-red-500">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<pre className="text-sm overflow-x-auto bg-red-50 text-red-800 p-4 rounded-md">
|
||||||
|
{JSON.stringify(error, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServiceTester;
|
||||||
48
internal-api-harness/src/components/ui/button.jsx
Normal file
48
internal-api-harness/src/components/ui/button.jsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
})
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
50
internal-api-harness/src/components/ui/card.jsx
Normal file
50
internal-api-harness/src/components/ui/card.jsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
||||||
|
{...props} />
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props} />
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props} />
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props} />
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props} />
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
11
internal-api-harness/src/components/ui/collapsible.jsx
Normal file
11
internal-api-harness/src/components/ui/collapsible.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
const Collapsible = CollapsiblePrimitive.Root
|
||||||
|
|
||||||
|
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
|
||||||
|
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
19
internal-api-harness/src/components/ui/input.jsx
Normal file
19
internal-api-harness/src/components/ui/input.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
})
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
16
internal-api-harness/src/components/ui/label.jsx
Normal file
16
internal-api-harness/src/components/ui/label.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
105
internal-api-harness/src/components/ui/select.jsx
Normal file
105
internal-api-harness/src/components/ui/select.jsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { ChevronDown } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
}
|
||||||
18
internal-api-harness/src/components/ui/textarea.jsx
Normal file
18
internal-api-harness/src/components/ui/textarea.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
})
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
17
internal-api-harness/src/firebase.js
Normal file
17
internal-api-harness/src/firebase.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Import the functions you need from the SDKs you need
|
||||||
|
import { initializeApp } from "firebase/app";
|
||||||
|
import { getAuth } from "firebase/auth";
|
||||||
|
|
||||||
|
// Your web app's Firebase configuration
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: import.meta.env.VITE_HARNESS_FIREBASE_API_KEY,
|
||||||
|
authDomain: import.meta.env.VITE_HARNESS_FIREBASE_AUTH_DOMAIN,
|
||||||
|
projectId: import.meta.env.VITE_HARNESS_FIREBASE_PROJECT_ID,
|
||||||
|
storageBucket: import.meta.env.VITE_HARNESS_FIREBASE_STORAGE_BUCKET,
|
||||||
|
messagingSenderId: import.meta.env.VITE_HARNESS_FIREBASE_MESSAGING_SENDER_ID,
|
||||||
|
appId: import.meta.env.VITE_HARNESS_FIREBASE_APP_ID
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize Firebase
|
||||||
|
const app = initializeApp(firebaseConfig);
|
||||||
|
export const auth = getAuth(app);
|
||||||
76
internal-api-harness/src/index.css
Normal file
76
internal-api-harness/src/index.css
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
internal-api-harness/src/lib/utils.js
Normal file
6
internal-api-harness/src/lib/utils.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
10
internal-api-harness/src/main.jsx
Normal file
10
internal-api-harness/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
13
internal-api-harness/src/pages/ApiPlaceholder.jsx
Normal file
13
internal-api-harness/src/pages/ApiPlaceholder.jsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const ApiPlaceholder = ({ title }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 mb-4">{title}</h1>
|
||||||
|
<div className="bg-slate-100 border border-slate-200 rounded-lg p-8 text-center">
|
||||||
|
<p className="text-slate-500">This page is a placeholder for the "{title}" API test harness.</p>
|
||||||
|
<p className="text-slate-500 mt-2">Implementation is pending.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiPlaceholder;
|
||||||
190
internal-api-harness/src/pages/EntityTester.jsx
Normal file
190
internal-api-harness/src/pages/EntityTester.jsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { krowSDK } from "@/api/krowSDK";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import ApiResponse from "@/components/ApiResponse";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
const entityNames = Object.keys(krowSDK.entities).sort();
|
||||||
|
|
||||||
|
const getPrettifiedJSON = (entity, method) => {
|
||||||
|
// Basic placeholder payloads. A more advanced SDK could provide detailed examples.
|
||||||
|
const payloads = {
|
||||||
|
get: { id: "some-id" },
|
||||||
|
create: { data: { property: "value" } },
|
||||||
|
update: { id: "some-id", data: { property: "new-value" } },
|
||||||
|
delete: { id: "some-id" },
|
||||||
|
filter: { where: { property: { _eq: "value" } } },
|
||||||
|
list: {}
|
||||||
|
};
|
||||||
|
return JSON.stringify(payloads[method] || {}, null, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EntityTester = () => {
|
||||||
|
const [selectedEntity, setSelectedEntity] = useState(null);
|
||||||
|
const [selectedMethod, setSelectedMethod] = useState(null);
|
||||||
|
const [response, setResponse] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [jsonInput, setJsonInput] = useState("");
|
||||||
|
const [jsonError, setJsonError] = useState(null);
|
||||||
|
|
||||||
|
const availableMethods = useMemo(() => {
|
||||||
|
if (!selectedEntity) return [];
|
||||||
|
return Object.keys(krowSDK.entities[selectedEntity]);
|
||||||
|
}, [selectedEntity]);
|
||||||
|
|
||||||
|
const handleEntityChange = (entity) => {
|
||||||
|
setSelectedEntity(entity);
|
||||||
|
setSelectedMethod(null);
|
||||||
|
setJsonInput("");
|
||||||
|
setJsonError(null);
|
||||||
|
setResponse(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMethodSelect = (method) => {
|
||||||
|
setSelectedMethod(method);
|
||||||
|
setJsonInput(getPrettifiedJSON(selectedEntity, method));
|
||||||
|
setJsonError(null);
|
||||||
|
setResponse(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJsonInputChange = (e) => {
|
||||||
|
setJsonInput(e.target.value);
|
||||||
|
try {
|
||||||
|
JSON.parse(e.target.value);
|
||||||
|
setJsonError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setJsonError("Invalid JSON format");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeApi = async () => {
|
||||||
|
if (!selectedEntity || !selectedMethod || jsonError) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setResponse(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = JSON.parse(jsonInput);
|
||||||
|
const sdkMethod = krowSDK.entities[selectedEntity][selectedMethod];
|
||||||
|
const res = await sdkMethod(params);
|
||||||
|
setResponse(res);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data || err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMethodForm = () => {
|
||||||
|
if (!selectedMethod) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 p-4 text-center text-slate-500 bg-slate-50 rounded-lg">
|
||||||
|
<p>Select a method to begin.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800">
|
||||||
|
Parameters for <code className="bg-slate-100 p-1 rounded text-sm">/{selectedEntity}/{selectedMethod}</code>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
This is a textarea for JSON input. A more advanced implementation could
|
||||||
|
dynamically generate a form based on the expected parameters of each
|
||||||
|
SDK method, but that requires metadata about each method's signature
|
||||||
|
which is not currently available in the mock client.
|
||||||
|
*/}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="params">JSON Payload</Label>
|
||||||
|
<Textarea
|
||||||
|
id="params"
|
||||||
|
name="params"
|
||||||
|
value={jsonInput}
|
||||||
|
onChange={handleJsonInputChange}
|
||||||
|
rows={8}
|
||||||
|
className={`font-mono text-sm ${jsonError ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
|
||||||
|
/>
|
||||||
|
{jsonError && <p className="text-xs text-red-600">{jsonError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={executeApi} disabled={loading || !!jsonError}>
|
||||||
|
{loading ? "Executing..." : "Execute"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Entity API Tester</h1>
|
||||||
|
<p className="text-slate-600 mt-1">
|
||||||
|
Select an entity and method, provide the required parameters in JSON format, and execute the API call.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>API Configuration</CardTitle>
|
||||||
|
<CardDescription>Choose a Base44 entity and method to interact with.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-start">
|
||||||
|
<div className="col-span-1">
|
||||||
|
<Label>Entity</Label>
|
||||||
|
<Select onValueChange={handleEntityChange} value={selectedEntity || ""}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select an entity" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{entityNames.map(entity => (
|
||||||
|
<SelectItem key={entity} value={entity}>{entity}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedEntity && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label>Method</Label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||||
|
{availableMethods.map(method => (
|
||||||
|
<Button
|
||||||
|
key={method}
|
||||||
|
variant={selectedMethod === method ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleMethodSelect(method)}
|
||||||
|
>
|
||||||
|
{method}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderMethodForm()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ApiResponse response={response} error={error} loading={loading} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EntityTester;
|
||||||
28
internal-api-harness/src/pages/GenerateImage.jsx
Normal file
28
internal-api-harness/src/pages/GenerateImage.jsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import ServiceTester from "@/components/ServiceTester";
|
||||||
|
|
||||||
|
const GenerateImage = () => {
|
||||||
|
const fields = [
|
||||||
|
{
|
||||||
|
name: "prompt",
|
||||||
|
label: "Prompt",
|
||||||
|
type: "textarea",
|
||||||
|
placeholder: "Enter a prompt for the image",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file",
|
||||||
|
label: "File",
|
||||||
|
type: "file",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ServiceTester
|
||||||
|
serviceName="Generate Image"
|
||||||
|
serviceDescription="Test the Generate Image service"
|
||||||
|
endpoint="/generate-image"
|
||||||
|
fields={fields}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GenerateImage;
|
||||||
31
internal-api-harness/src/pages/Home.jsx
Normal file
31
internal-api-harness/src/pages/Home.jsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
const Home = () => {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Welcome to KROW API Test Harness</h1>
|
||||||
|
<p className="text-slate-600 mt-1">
|
||||||
|
Your dedicated tool for rapid and authenticated testing of KROW backend services.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Get Started</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Use the sidebar navigation to select an API category and then choose a specific endpoint to test.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-slate-700">
|
||||||
|
This tool automatically handles Firebase authentication, injecting the necessary ID tokens into your API requests.
|
||||||
|
Simply log in, select an API, provide the required parameters, and execute to see the raw JSON response.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
||||||
37
internal-api-harness/src/pages/Login.jsx
Normal file
37
internal-api-harness/src/pages/Login.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { GoogleAuthProvider, signInWithPopup, setPersistence, browserLocalPersistence } from "firebase/auth";
|
||||||
|
import { useAuthState } from "react-firebase-hooks/auth";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { auth } from "../firebase";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const Login = () => {
|
||||||
|
const [user, loading] = useAuthState(auth);
|
||||||
|
|
||||||
|
const handleGoogleLogin = async () => {
|
||||||
|
const provider = new GoogleAuthProvider();
|
||||||
|
try {
|
||||||
|
await setPersistence(auth, browserLocalPersistence);
|
||||||
|
await signInWithPopup(auth, provider);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error signing in with Google", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user is logged in, redirect to the home page
|
||||||
|
if (user) {
|
||||||
|
return <Navigate to="/" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no user, show the login button
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<Button onClick={handleGoogleLogin}>Sign in with Google</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
49
internal-api-harness/src/pages/auth/GetMe.jsx
Normal file
49
internal-api-harness/src/pages/auth/GetMe.jsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import ApiResponse from "@/components/ApiResponse";
|
||||||
|
import { krowSDK } from "@/api/krowSDK";
|
||||||
|
|
||||||
|
const GetMe = () => {
|
||||||
|
const [response, setResponse] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleGetMe = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResponse(null);
|
||||||
|
try {
|
||||||
|
const res = await krowSDK.auth.me();
|
||||||
|
setResponse(res);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data || err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 mb-4">Get Me</h1>
|
||||||
|
<p className="text-slate-600 mb-6">Fetches the currently authenticated user's profile.</p>
|
||||||
|
|
||||||
|
<Card className="max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Test `/auth/me`</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button onClick={handleGetMe} disabled={loading}>
|
||||||
|
{loading ? "Loading..." : "Execute"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ApiResponse response={response} error={error} loading={loading} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// We need to re-import Card components because they are not globally available.
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default GetMe;
|
||||||
74
internal-api-harness/src/pages/core/CreateSignedUrl.jsx
Normal file
74
internal-api-harness/src/pages/core/CreateSignedUrl.jsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import ApiResponse from "@/components/ApiResponse";
|
||||||
|
import { krowSDK } from "@/api/krowSDK";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
|
||||||
|
|
||||||
|
const CreateSignedUrl = () => {
|
||||||
|
const [response, setResponse] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
file_uri: "gs://your-bucket/private-file.pdf",
|
||||||
|
expires_in: 3600,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { id, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [id]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateUrl = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResponse(null);
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
...formData,
|
||||||
|
expires_in: parseInt(formData.expires_in, 10),
|
||||||
|
};
|
||||||
|
const res = await krowSDK.integrations.Core.CreateFileSignedUrl(params);
|
||||||
|
setResponse(res);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data || err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 mb-4">Create Signed URL</h1>
|
||||||
|
<p className="text-slate-600 mb-6">Tests the `integrations.Core.CreateFileSignedUrl` endpoint.</p>
|
||||||
|
|
||||||
|
<Card className="max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Test `/createSignedUrl`</CardTitle>
|
||||||
|
<CardDescription>Creates a temporary access URL for a private file stored in GCS.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleCreateUrl} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="file_uri">File URI</Label>
|
||||||
|
<Input id="file_uri" value={formData.file_uri} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="expires_in">Expires In (seconds)</Label>
|
||||||
|
<Input id="expires_in" type="number" value={formData.expires_in} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Creating..." : "Create Signed URL"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ApiResponse response={response} error={error} loading={loading} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateSignedUrl;
|
||||||
81
internal-api-harness/src/pages/core/InvokeLLM.jsx
Normal file
81
internal-api-harness/src/pages/core/InvokeLLM.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import ApiResponse from "@/components/ApiResponse";
|
||||||
|
import { krowSDK } from "@/api/krowSDK";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
const InvokeLLM = () => {
|
||||||
|
const [response, setResponse] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
prompt: "Extract the total amount from the attached invoice.",
|
||||||
|
response_json_schema: JSON.stringify({ type: "object", properties: { total_amount: { type: "number" } } }, null, 2),
|
||||||
|
file_urls: "https://example.com/invoice.pdf",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { id, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [id]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInvokeLLM = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResponse(null);
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
...formData,
|
||||||
|
response_json_schema: JSON.parse(formData.response_json_schema),
|
||||||
|
file_urls: formData.file_urls.split(',').map(url => url.trim()),
|
||||||
|
};
|
||||||
|
const res = await krowSDK.integrations.Core.InvokeLLM(params);
|
||||||
|
setResponse(res);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data || err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 mb-4">Invoke LLM</h1>
|
||||||
|
<p className="text-slate-600 mb-6">Tests the `integrations.Core.InvokeLLM` endpoint.</p>
|
||||||
|
|
||||||
|
<Card className="max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Test `/invokeLLM`</CardTitle>
|
||||||
|
<CardDescription>Calls a large language model (e.g., Vertex AI).</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleInvokeLLM} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="prompt">Prompt</Label>
|
||||||
|
<Textarea id="prompt" value={formData.prompt} onChange={handleChange} rows={4} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="response_json_schema">Response JSON Schema</Label>
|
||||||
|
<Textarea id="response_json_schema" value={formData.response_json_schema} onChange={handleChange} rows={6} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="file_urls">File URLs (comma-separated)</Label>
|
||||||
|
<Input id="file_urls" value={formData.file_urls} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Invoking..." : "Invoke LLM"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ApiResponse response={response} error={error} loading={loading} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InvokeLLM;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user