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
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
*.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.
|
||||
|
||||
# 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').
|
||||
.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 ---
|
||||
GCP_DEV_PROJECT_ID := krow-workforce-dev
|
||||
GCP_STAGING_PROJECT_ID := krow-workforce-staging
|
||||
@@ -61,6 +67,15 @@ help:
|
||||
@echo " make dev - Starts the local web frontend server."
|
||||
@echo " make build - Builds the web frontend for production."
|
||||
@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 " 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)."
|
||||
@@ -72,7 +87,7 @@ help:
|
||||
@echo ""
|
||||
@echo " --- PROJECT MANAGEMENT & TOOLS ---"
|
||||
@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 install-git-hooks - Installs git pre-push hook to protect main/dev branches."
|
||||
@echo ""
|
||||
@@ -145,6 +160,23 @@ admin-build:
|
||||
@node scripts/patch-admin-layout-for-env-label.js
|
||||
@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
|
||||
@echo "--> Building and deploying Admin Console to Cloud Run [$(ENV)]..."
|
||||
@echo " - Step 1: Building container image..."
|
||||
@@ -176,7 +208,7 @@ configure-iap-launchpad:
|
||||
@gcloud run services add-iam-policy-binding $(CR_LAUNCHPAD_SERVICE_NAME) \
|
||||
--region=$(CR_LAUNCHPAD_REGION) \
|
||||
--project=$(GCP_DEV_PROJECT_ID) \
|
||||
--member="serviceAccount:$(IAP_SERVICE_ACCOUNT)" \
|
||||
--member=\"serviceAccount:$(IAP_SERVICE_ACCOUNT)\" \
|
||||
--role='roles/run.invoker' \
|
||||
--quiet
|
||||
@echo " - Adding users from iap-users.txt..."
|
||||
@@ -188,7 +220,7 @@ configure-iap-launchpad:
|
||||
--resource-type=cloud-run \
|
||||
--service=$(CR_LAUNCHPAD_SERVICE_NAME) \
|
||||
--region=$(CR_LAUNCHPAD_REGION) \
|
||||
--member="$$member" \
|
||||
--member=\"$$member\" \
|
||||
--role='roles/iap.httpsResourceAccessor' \
|
||||
--quiet; \
|
||||
done
|
||||
@@ -200,7 +232,7 @@ configure-iap-admin:
|
||||
@gcloud run services add-iam-policy-binding $(CR_ADMIN_SERVICE_NAME) \
|
||||
--region=$(CR_ADMIN_REGION) \
|
||||
--project=$(GCP_PROJECT_ID) \
|
||||
--member="serviceAccount:$(IAP_SERVICE_ACCOUNT)" \
|
||||
--member=\"serviceAccount:$(IAP_SERVICE_ACCOUNT)\" \
|
||||
--role='roles/run.invoker' \
|
||||
--quiet
|
||||
@echo " - Adding users from iap-users.txt..."
|
||||
@@ -212,7 +244,7 @@ configure-iap-admin:
|
||||
--resource-type=cloud-run \
|
||||
--service=$(CR_ADMIN_SERVICE_NAME) \
|
||||
--region=$(CR_ADMIN_REGION) \
|
||||
--member="$$member" \
|
||||
--member=\"$$member\" \
|
||||
--role='roles/iap.httpsResourceAccessor' \
|
||||
--quiet; \
|
||||
done
|
||||
@@ -237,7 +269,7 @@ remove-iap-user:
|
||||
--resource-type=cloud-run \
|
||||
--service=$(IAP_SERVICE_NAME) \
|
||||
--region=$(IAP_SERVICE_REGION) \
|
||||
--member="$(USER)" \
|
||||
--member=\"$(USER)\" \
|
||||
--role='roles/iap.httpsResourceAccessor' \
|
||||
--quiet
|
||||
@echo "✅ User removed from IAP."
|
||||
@@ -312,4 +344,69 @@ dataconnect-init:
|
||||
dataconnect-deploy:
|
||||
@echo "--> Deploying Firebase Data Connect schemas to [$(ENV)] (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"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": {
|
||||
|
||||
@@ -93,6 +93,35 @@
|
||||
#diagram-container:active {
|
||||
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-overlay {
|
||||
@@ -566,150 +595,96 @@
|
||||
});
|
||||
|
||||
// Build hierarchical structure from paths
|
||||
function buildDiagramHierarchy(diagrams) {
|
||||
const hierarchy = {};
|
||||
function buildHierarchy(items, pathPrefix) {
|
||||
const hierarchy = { _root: { _items: [], _children: {} } };
|
||||
|
||||
diagrams.forEach(diagram => {
|
||||
const parts = diagram.path.split('/');
|
||||
const relevantParts = parts.slice(2, -1); // Remove 'assets/diagrams/' 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(diagram);
|
||||
} else {
|
||||
// Root level diagrams
|
||||
if (!hierarchy._root) {
|
||||
hierarchy._root = { _items: [], _children: {} };
|
||||
}
|
||||
hierarchy._root._items.push(diagram);
|
||||
items.forEach(item => {
|
||||
let relativePath = item.path;
|
||||
if (relativePath.startsWith('./')) {
|
||||
relativePath = relativePath.substring(2);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Build hierarchical structure from paths (for documents)
|
||||
function buildDocumentHierarchy(documents) {
|
||||
const hierarchy = {};
|
||||
|
||||
documents.forEach(doc => {
|
||||
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;
|
||||
}
|
||||
// Generic function to create accordion navigation
|
||||
function createAccordionNavigation(hierarchy, parentElement, createLinkFunction, sectionTitle) {
|
||||
const createAccordion = (title, items, children) => {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'mb-1';
|
||||
|
||||
// Create navigation from hierarchy
|
||||
function createNavigation(hierarchy, parentElement, level = 0) {
|
||||
// First, show root level items if any
|
||||
if (hierarchy._root && hierarchy._root._items.length > 0) {
|
||||
const mainHeading = document.createElement('div');
|
||||
mainHeading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mt-6 mb-3';
|
||||
mainHeading.textContent = 'Diagrams';
|
||||
parentElement.appendChild(mainHeading);
|
||||
const button = document.createElement('button');
|
||||
button.className = 'accordion-button';
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
button.innerHTML = `
|
||||
<span class="font-medium">${title.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}</span>
|
||||
<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>
|
||||
`;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'accordion-panel pl-4 pt-1';
|
||||
|
||||
hierarchy._root._items.forEach(diagram => {
|
||||
createDiagramLink(diagram, parentElement, 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Then process nested categories
|
||||
Object.keys(hierarchy).forEach(key => {
|
||||
if (key === '_items' || key === '_children' || key === '_root') return;
|
||||
|
||||
const section = hierarchy[key];
|
||||
const heading = document.createElement('div');
|
||||
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(diagram => {
|
||||
createDiagramLink(diagram, parentElement, level);
|
||||
if (items) {
|
||||
items.forEach(item => createLinkFunction(item, panel, 1));
|
||||
}
|
||||
|
||||
if (children) {
|
||||
Object.keys(children).forEach(childKey => {
|
||||
const childSection = children[childKey];
|
||||
const childHeading = document.createElement('div');
|
||||
childHeading.className = 'px-4 pt-2 pb-1 text-xs font-semibold text-gray-400 uppercase tracking-wider';
|
||||
childHeading.textContent = childKey.replace(/-/g, ' ');
|
||||
panel.appendChild(childHeading);
|
||||
childSection._items.forEach(item => createLinkFunction(item, panel, 2));
|
||||
});
|
||||
}
|
||||
|
||||
// Recursively add children
|
||||
if (section._children && Object.keys(section._children).length > 0) {
|
||||
createNavigation(section._children, parentElement, level + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create document navigation from hierarchy
|
||||
function createDocumentNavigation(hierarchy, parentElement, level = 0) {
|
||||
// First, show root level items if any
|
||||
if (hierarchy._root && hierarchy._root._items.length > 0) {
|
||||
const mainHeading = document.createElement('div');
|
||||
mainHeading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mt-6 mb-3';
|
||||
mainHeading.textContent = 'Documentation';
|
||||
parentElement.appendChild(mainHeading);
|
||||
|
||||
hierarchy._root._items.forEach(doc => {
|
||||
createDocumentLink(doc, parentElement, 0);
|
||||
button.addEventListener('click', () => {
|
||||
const isExpanded = button.getAttribute('aria-expanded') === 'true';
|
||||
button.setAttribute('aria-expanded', !isExpanded);
|
||||
if (!isExpanded) {
|
||||
panel.style.maxHeight = panel.scrollHeight + 'px';
|
||||
} else {
|
||||
panel.style.maxHeight = '0px';
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
Object.keys(hierarchy).forEach(key => {
|
||||
if (key === '_items' || key === '_children' || key === '_root') return;
|
||||
|
||||
const section = hierarchy[key];
|
||||
const heading = document.createElement('div');
|
||||
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);
|
||||
}
|
||||
|
||||
// Process categories as accordions
|
||||
Object.keys(hierarchy._root._children).forEach(key => {
|
||||
if (key.startsWith('_')) return;
|
||||
const section = hierarchy._root._children[key];
|
||||
const accordion = createAccordion(key, section._items, section._children);
|
||||
parentElement.appendChild(accordion);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -717,8 +692,8 @@
|
||||
function createDocumentLink(doc, parentElement, level) {
|
||||
const link = document.createElement('a');
|
||||
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' +
|
||||
(level > 0 ? ' pl-8' : '');
|
||||
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 ? ' ' : '');
|
||||
link.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
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>
|
||||
</svg>`;
|
||||
|
||||
link.innerHTML = `${iconSvg}<span class="text-sm">${doc.title}</span>`;
|
||||
link.innerHTML = `${iconSvg}<span class="truncate">${doc.title}</span>`;
|
||||
parentElement.appendChild(link);
|
||||
}
|
||||
|
||||
@@ -736,26 +711,18 @@
|
||||
function createDiagramLink(diagram, parentElement, level) {
|
||||
const link = document.createElement('a');
|
||||
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' +
|
||||
(level > 0 ? ' pl-8' : '');
|
||||
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 ? ' ' : '');
|
||||
link.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
showView('diagram', link, diagram.path, diagram.title, diagram.type);
|
||||
};
|
||||
|
||||
// Get icon based on type or custom icon
|
||||
let iconSvg = '';
|
||||
if (diagram.type === '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>`;
|
||||
}
|
||||
const 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="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>
|
||||
</svg>`;
|
||||
|
||||
link.innerHTML = iconSvg + '<span class="text-sm">' + diagram.title + '</span>';
|
||||
link.innerHTML = `${iconSvg}<span class="truncate">${diagram.title}</span>`;
|
||||
parentElement.appendChild(link);
|
||||
}
|
||||
|
||||
@@ -770,12 +737,11 @@
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
console.log('Loaded config:', text);
|
||||
allDiagrams = JSON.parse(text);
|
||||
|
||||
if (allDiagrams && allDiagrams.length > 0) {
|
||||
const hierarchy = buildDiagramHierarchy(allDiagrams);
|
||||
createNavigation(hierarchy, dynamicSection);
|
||||
const hierarchy = buildHierarchy(allDiagrams, 'assets/diagrams/');
|
||||
createAccordionNavigation(hierarchy, dynamicSection, createDiagramLink, 'Diagrams');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading diagrams configuration:', error);
|
||||
@@ -801,12 +767,11 @@
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
console.log('Loaded documents config:', text);
|
||||
allDocuments = JSON.parse(text);
|
||||
|
||||
if (allDocuments && allDocuments.length > 0) {
|
||||
const hierarchy = buildDocumentHierarchy(allDocuments);
|
||||
createDocumentNavigation(hierarchy, documentationSection);
|
||||
const hierarchy = buildHierarchy(allDocuments, 'assets/documents/');
|
||||
createAccordionNavigation(hierarchy, documentationSection, createDocumentLink, 'Documentation');
|
||||
}
|
||||
} catch (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": {
|
||||
"@base44/sdk": "^0.1.2",
|
||||
"@dataconnect/generated": "file:src/dataconnect-generated",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hookform/resolvers": "^4.1.2",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
@@ -1740,6 +1741,23 @@
|
||||
"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": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.3.tgz",
|
||||
@@ -4491,6 +4509,12 @@
|
||||
"@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": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -5274,6 +5298,15 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -8375,6 +8408,12 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -8436,6 +8475,29 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"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": {
|
||||
"version": "0.17.0",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@base44/sdk": "^0.1.2",
|
||||
"@dataconnect/generated": "file:src/dataconnect-generated",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hookform/resolvers": "^4.1.2",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@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 Task = base44.entities.Task;
|
||||
|
||||
export const TaskComment = base44.entities.TaskComment;
|
||||
|
||||
|
||||
|
||||
// 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: "",
|
||||
include_backup: false,
|
||||
backup_staff_count: 0,
|
||||
vendor_id: "", // Added vendor_id
|
||||
vendor_name: "", // Added vendor_name
|
||||
shifts: [{
|
||||
shift_name: "Shift 1",
|
||||
location_address: "",
|
||||
@@ -72,6 +74,7 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
||||
const currentUserData = currentUser || user;
|
||||
const userRole = currentUserData?.user_role || currentUserData?.role || "admin";
|
||||
const isVendor = userRole === "vendor";
|
||||
const isClient = userRole === "client"; // Added isClient
|
||||
|
||||
const { data: businesses = [] } = useQuery({
|
||||
queryKey: ['businesses'],
|
||||
@@ -79,6 +82,12 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: vendors = [] } = useQuery({ // Added vendors query
|
||||
queryKey: ['vendors-for-order'],
|
||||
queryFn: () => base44.entities.Vendor.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: allRates = [] } = useQuery({
|
||||
queryKey: ['vendor-rates-all'],
|
||||
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();
|
||||
|
||||
// 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(() => {
|
||||
if (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) => {
|
||||
if (!startTime || !endTime) return 0;
|
||||
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);
|
||||
};
|
||||
|
||||
const getRateForRole = (roleName) => {
|
||||
const rate = allRates.find(r => r.role_name === roleName && r.is_active);
|
||||
return rate ? parseFloat(rate.client_rate || 0) : 0;
|
||||
const getRateForRole = (roleName, vendorId = null) => { // Modified getRateForRole
|
||||
const targetVendorId = vendorId || formData.vendor_id;
|
||||
|
||||
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) => {
|
||||
@@ -138,6 +207,8 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
||||
if (field === 'role') {
|
||||
const rate = getRateForRole(value);
|
||||
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') {
|
||||
@@ -176,7 +247,9 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
||||
uniform: "Type 1",
|
||||
break_minutes: 30, // Default to 30 min non-payable
|
||||
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 };
|
||||
});
|
||||
@@ -588,6 +661,36 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<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) */}
|
||||
<div>
|
||||
<Label className="text-xs">Hub *</Label>
|
||||
|
||||
@@ -1,84 +1,160 @@
|
||||
import React from "react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { MapPin, Plus } from "lucide-react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Clock, MapPin, Users, DollarSign, UserPlus } from "lucide-react";
|
||||
import SmartAssignModal from "./SmartAssignModal";
|
||||
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 (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100 pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">{shift.shift_name || "Shift 1"}</CardTitle>
|
||||
<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" />
|
||||
<>
|
||||
<Card className="bg-white border-2 border-slate-200 shadow-sm">
|
||||
<CardHeader className="border-b border-slate-100 bg-slate-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Location:</p>
|
||||
<p className="text-slate-600">{shift.location || "848 East Glen Road New York CA, USA"}</p>
|
||||
<CardTitle className="text-lg font-bold text-slate-900">
|
||||
{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>
|
||||
<Badge className="bg-[#0A39DF] text-white font-semibold px-3 py-1.5">
|
||||
{roles.length} Role{roles.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 hover:bg-slate-50">
|
||||
<TableHead className="text-xs">Unpaid break</TableHead>
|
||||
<TableHead className="text-xs">Count</TableHead>
|
||||
<TableHead className="text-xs">Assigned</TableHead>
|
||||
<TableHead className="text-xs">Uniform type</TableHead>
|
||||
<TableHead className="text-xs">Price</TableHead>
|
||||
<TableHead className="text-xs">Amount</TableHead>
|
||||
<TableHead className="text-xs">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(shift.assigned_staff || []).length > 0 ? (
|
||||
shift.assigned_staff.map((staff, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-xs">{shift.unpaid_break || 0}</TableCell>
|
||||
<TableCell className="text-xs">1</TableCell>
|
||||
<TableCell className="text-xs">0</TableCell>
|
||||
<TableCell className="text-xs">{shift.uniform_type || "uniform type"}</TableCell>
|
||||
<TableCell className="text-xs">${shift.price || 23}</TableCell>
|
||||
<TableCell className="text-xs">{shift.amount || 120}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" className="text-xs">⋮</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-4 text-slate-500 text-xs">
|
||||
No staff assigned yet
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
{roles.map((role, idx) => {
|
||||
const requiredCount = role.count || 1;
|
||||
const assignedCount = event?.assigned_staff?.filter(s => s.role === role.role)?.length || 0;
|
||||
const remainingCount = Math.max(requiredCount - assignedCount, 0);
|
||||
|
||||
// Consistent status color logic
|
||||
const statusColor = remainingCount === 0
|
||||
? "bg-green-100 text-green-700 border-green-300"
|
||||
: assignedCount > 0
|
||||
? "bg-blue-100 text-blue-700 border-blue-300"
|
||||
: "bg-slate-100 text-slate-700 border-slate-300";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="border-2 border-slate-200 rounded-xl p-4 hover:shadow-sm transition-shadow bg-white"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="font-bold text-slate-900 text-lg">{role.role}</h4>
|
||||
<Badge className={`${statusColor} border-2 font-bold px-3 py-1`}>
|
||||
{assignedCount} / {requiredCount} Assigned
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||
{role.start_time && role.end_time && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
{convertTo12Hour(role.start_time)} - {convertTo12Hour(role.end_time)}
|
||||
</span>
|
||||
)}
|
||||
{role.department && (
|
||||
<Badge variant="outline" className="text-xs border-slate-300">
|
||||
{role.department}
|
||||
</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 SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
{...props}>
|
||||
<SliderPrimitive.Track
|
||||
className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
className={`relative flex w-full touch-none select-none items-center ${className}`}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-slate-100">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-[#0A39DF]" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
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.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" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
export { Slider }
|
||||
@@ -1,41 +1,33 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
className={`inline-flex h-10 items-center justify-center rounded-md bg-slate-100 p-1 text-slate-500 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
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}`}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
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}`}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
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 { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
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 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() {
|
||||
const navigate = useNavigate();
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [reorderModalOpen, setReorderModalOpen] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const queryClient = useQueryClient();
|
||||
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({
|
||||
queryKey: ['current-user'],
|
||||
queryKey: ['current-user-client-orders'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: events } = useQuery({
|
||||
queryKey: ['client-events'],
|
||||
const { data: allEvents = [] } = useQuery({
|
||||
queryKey: ['all-events-client'],
|
||||
queryFn: () => base44.entities.Event.list('-date'),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
// Filter events by current client
|
||||
const clientEvents = events.filter(e =>
|
||||
e.client_email === user?.email || e.created_by === user?.email
|
||||
);
|
||||
const clientEvents = useMemo(() => {
|
||||
return allEvents.filter(e =>
|
||||
e.client_email === user?.email ||
|
||||
e.business_name === user?.company_name ||
|
||||
e.created_by === user?.email
|
||||
);
|
||||
}, [allEvents, user]);
|
||||
|
||||
const filteredEvents = statusFilter === "all"
|
||||
? clientEvents
|
||||
: clientEvents.filter(e => {
|
||||
if (statusFilter === "rapid_request") return e.is_rapid_request;
|
||||
if (statusFilter === "pending") return e.status?.toLowerCase() === "pending" || e.status?.toLowerCase() === "draft";
|
||||
return e.status?.toLowerCase() === statusFilter;
|
||||
const cancelOrderMutation = useMutation({
|
||||
mutationFn: (orderId) => base44.entities.Event.update(orderId, { status: "Canceled" }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['all-events-client'] });
|
||||
toast({
|
||||
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 colors = {
|
||||
'pending': 'bg-yellow-100 text-yellow-700',
|
||||
'draft': 'bg-gray-100 text-gray-700',
|
||||
'confirmed': 'bg-green-100 text-green-700',
|
||||
'active': 'bg-blue-100 text-blue-700',
|
||||
'completed': 'bg-slate-100 text-slate-700',
|
||||
'canceled': 'bg-red-100 text-red-700',
|
||||
'cancelled': 'bg-red-100 text-red-700',
|
||||
const filteredOrders = useMemo(() => { // Renamed from filteredEvents
|
||||
let filtered = clientEvents;
|
||||
|
||||
if (searchTerm) {
|
||||
const lower = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(e =>
|
||||
e.event_name?.toLowerCase().includes(lower) ||
|
||||
e.business_name?.toLowerCase().includes(lower) ||
|
||||
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 = {
|
||||
total: clientEvents.length,
|
||||
rapidRequest: clientEvents.filter(e => e.is_rapid_request).length,
|
||||
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 getEventTimes = (event) => {
|
||||
const firstShift = event.shifts?.[0];
|
||||
const rolesInFirstShift = firstShift?.roles || [];
|
||||
|
||||
const handleQuickReorder = (event) => {
|
||||
setSelectedEvent(event);
|
||||
setReorderModalOpen(true);
|
||||
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) : "-"
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-[#1C323E]">My Orders</h1>
|
||||
<p className="text-slate-500 mt-1">View and manage your event orders</p>
|
||||
<div className="max-w-[1800px] mx-auto space-y-6">
|
||||
<div className=""> {/* Removed mb-6 */}
|
||||
<h1 className="text-2xl font-bold text-slate-900">My Orders</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">View and manage all your 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>
|
||||
<Button
|
||||
onClick={() => navigate(createPageUrl("CreateEvent"))}
|
||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Order
|
||||
</Button>
|
||||
<Tabs value={statusFilter} onValueChange={setStatusFilter} className="w-fit"> {/* Replaced Select with Tabs */}
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="active">Active</TabsTrigger>
|
||||
<TabsTrigger value="completed">Completed</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-6 mb-8">
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<FileText className="w-8 h-8 text-[#0A39DF]" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">Total Orders</p>
|
||||
<p className="text-3xl font-bold text-[#1C323E]">{stats.total}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-slate-200 shadow-sm"> {/* Card class updated */}
|
||||
<CardContent className="p-0"> {/* CardContent padding updated */}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 hover:bg-slate-50"> {/* TableRow class updated */}
|
||||
<TableHead className="font-semibold text-slate-700">Order</TableHead> {/* Updated */}
|
||||
<TableHead className="font-semibold text-slate-700">Date</TableHead> {/* Updated */}
|
||||
<TableHead className="font-semibold text-slate-700">Location</TableHead> {/* Updated */}
|
||||
<TableHead className="font-semibold text-slate-700">Time</TableHead> {/* Updated */}
|
||||
<TableHead className="font-semibold text-slate-700">Status</TableHead> {/* Updated */}
|
||||
<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">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Zap className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">Rapid Requests</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>
|
||||
return (
|
||||
<TableRow key={order.id} className="hover:bg-slate-50">
|
||||
<TableCell> {/* Order cell */}
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{order.event_name}</p>
|
||||
<p className="text-xs text-slate-500">{order.business_name || "—"}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => navigate(createPageUrl("EventDetail") + `?id=${event.id}`)}
|
||||
variant="outline"
|
||||
className="hover:bg-[#0A39DF] hover:text-white"
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleQuickReorder(event)}
|
||||
className="bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 text-white shadow-lg"
|
||||
size="lg"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5 mr-2" />
|
||||
Reorder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{event.notes && (
|
||||
<div className="mt-3 p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-sm text-slate-600">{event.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-12 text-center">
|
||||
<FileText className="w-16 h-16 mx-auto text-slate-300 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-slate-700 mb-2">No orders found</h3>
|
||||
<p className="text-slate-500 mb-6">Get started by creating your first order</p>
|
||||
<Button
|
||||
onClick={() => navigate(createPageUrl("CreateEvent"))}
|
||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Order
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Reorder Modal */}
|
||||
{selectedEvent && (
|
||||
<QuickReorderModal
|
||||
event={selectedEvent}
|
||||
open={reorderModalOpen}
|
||||
onOpenChange={setReorderModalOpen}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell> {/* Date cell */}
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">
|
||||
{safeFormatDate(order.date, 'MMM dd, yyyy')}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{safeFormatDate(order.date, 'EEEE')}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell> {/* Location cell */}
|
||||
<div className="flex items-center gap-1.5 text-sm text-slate-600">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400" />
|
||||
{order.hub || order.event_location || "—"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell> {/* Time cell */}
|
||||
<div className="flex items-center gap-1 text-sm text-slate-600">
|
||||
<Clock className="w-3.5 h-3.5 text-slate-400" />
|
||||
{startTime} - {endTime}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell> {/* Status cell */}
|
||||
{getStatusBadge(order)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center"> {/* Staff cell */}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Badge className={assignment.badgeClass}>
|
||||
{assignment.assigned} / {assignment.requested}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-slate-500 font-medium">
|
||||
{assignment.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center"> {/* Invoice cell */}
|
||||
<div className="flex items-center justify-center">
|
||||
<Button // Changed from a div to a Button for better accessibility
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => invoiceReady && navigate(createPageUrl('Invoices'))}
|
||||
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'}`}
|
||||
disabled={!invoiceReady}
|
||||
title={invoiceReady ? "View Invoice" : "Invoice not available"}
|
||||
>
|
||||
<FileText className={`w-5 h-5 ${invoiceReady ? 'text-blue-600' : 'text-slate-400'}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell> {/* Actions cell */}
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
import React, { useState } from "react";
|
||||
import React 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 EventFormWizard from "@/components/events/EventFormWizard";
|
||||
import AIOrderAssistant from "@/components/events/AIOrderAssistant";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sparkles, FileText, X } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, AlertTriangle } from "lucide-react";
|
||||
import { detectAllConflicts, ConflictAlert } from "@/components/scheduling/ConflictDetection";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default function CreateEvent() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [useAI, setUseAI] = useState(false);
|
||||
const [aiExtractedData, setAiExtractedData] = useState(null);
|
||||
const [pendingEvent, setPendingEvent] = React.useState(null);
|
||||
const [showConflictWarning, setShowConflictWarning] = React.useState(false);
|
||||
|
||||
const { data: currentUser } = useQuery({
|
||||
queryKey: ['current-user-create-event'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: allEvents = [] } = useQuery({
|
||||
queryKey: ['events-for-conflict-check'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const createEventMutation = useMutation({
|
||||
mutationFn: (eventData) => base44.entities.Event.create(eventData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['client-events'] });
|
||||
toast({
|
||||
title: "✅ Event Created",
|
||||
description: "Your event has been created successfully.",
|
||||
@@ -42,107 +49,98 @@ export default function CreateEvent() {
|
||||
});
|
||||
|
||||
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) => {
|
||||
setAiExtractedData(extractedData);
|
||||
setUseAI(false);
|
||||
const handleConfirmWithConflicts = () => {
|
||||
if (pendingEvent) {
|
||||
createEventMutation.mutate(pendingEvent);
|
||||
setShowConflictWarning(false);
|
||||
setPendingEvent(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelConflicts = () => {
|
||||
setShowConflictWarning(false);
|
||||
setPendingEvent(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Header with AI Toggle */}
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<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">
|
||||
{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>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={useAI ? "default" : "outline"}
|
||||
onClick={() => setUseAI(true)}
|
||||
className={useAI ? "bg-gradient-to-r from-[#0A39DF] to-purple-600" : ""}
|
||||
>
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate(createPageUrl("ClientDashboard"))}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* AI Assistant Interface */}
|
||||
<AnimatePresence>
|
||||
{useAI && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
>
|
||||
<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>
|
||||
{/* Conflict Warning Modal */}
|
||||
{showConflictWarning && pendingEvent && (
|
||||
<Card className="mb-6 border-2 border-orange-500">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<AlertTriangle className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
<p className="text-sm text-green-700 mb-3">
|
||||
The form has been pre-filled with information from your conversation. Review and edit as needed.
|
||||
</p>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-slate-900 mb-1">
|
||||
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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAiExtractedData(null);
|
||||
setUseAI(true);
|
||||
}}
|
||||
className="border-green-300 text-green-700 hover:bg-green-100"
|
||||
onClick={handleCancelConflicts}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Chat with AI Again
|
||||
Go Back & Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmWithConflicts}
|
||||
className="bg-orange-600 hover:bg-orange-700"
|
||||
>
|
||||
Create Anyway
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EventFormWizard
|
||||
event={aiExtractedData}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={createEventMutation.isPending}
|
||||
currentUser={currentUser}
|
||||
onCancel={() => navigate(createPageUrl("Events"))}
|
||||
/>
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<EventFormWizard
|
||||
event={null}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={createEventMutation.isPending}
|
||||
currentUser={currentUser}
|
||||
onCancel={() => navigate(createPageUrl("ClientDashboard"))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,11 +6,104 @@ import { Link, useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 EcosystemWheel from "@/components/dashboard/EcosystemWheel";
|
||||
import QuickMetrics from "@/components/dashboard/QuickMetrics";
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
@@ -28,6 +121,13 @@ export default function Dashboard() {
|
||||
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 uniqueDepartments = [...new Set(staff.map(s => s.department).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")}>
|
||||
<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" />
|
||||
View All Events
|
||||
View All Orders
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
@@ -143,6 +243,133 @@ export default function Dashboard() {
|
||||
/>
|
||||
</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 */}
|
||||
<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">
|
||||
|
||||
@@ -1,52 +1,48 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
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 { 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 { ArrowLeft, Bell, RefreshCw } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import ShiftCard from "@/components/events/ShiftCard";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} 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 { format } from "date-fns";
|
||||
|
||||
const statusColors = {
|
||||
Draft: "bg-gray-100 text-gray-800",
|
||||
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 "-";
|
||||
const safeFormatDate = (dateString) => {
|
||||
if (!dateString) return "—";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return "-";
|
||||
return format(date, formatStr);
|
||||
return format(new Date(dateString), "MMMM d, yyyy");
|
||||
} catch {
|
||||
return "-";
|
||||
return "—";
|
||||
}
|
||||
};
|
||||
|
||||
export default function EventDetail() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [showNotifyDialog, setShowNotifyDialog] = useState(false);
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const eventId = urlParams.get('id');
|
||||
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({
|
||||
queryKey: ['events'],
|
||||
@@ -54,208 +50,314 @@ export default function EventDetail() {
|
||||
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 handleReorder = () => {
|
||||
if (!event) return; // Should not happen if event is loaded, but for safety
|
||||
// Cancel order mutation
|
||||
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 = {
|
||||
event_name: event.event_name,
|
||||
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));
|
||||
const handleNotifyStaff = async () => {
|
||||
const assignedStaff = event?.assigned_staff || [];
|
||||
|
||||
toast({
|
||||
title: "Reordering Event",
|
||||
description: `Creating new order based on "${event.event_name}"`,
|
||||
});
|
||||
for (const staff of assignedStaff) {
|
||||
try {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-[1600px] mx-auto">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(createPageUrl("Events"))}>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">{event.event_name}</h1>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{(event.status === "Completed" || event.status === "Canceled") && (
|
||||
<Button
|
||||
onClick={handleReorder}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
<div className="p-4 md:p-8">
|
||||
<div className="max-w-7xl 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(-1)}
|
||||
>
|
||||
<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" />
|
||||
Reorder
|
||||
<Edit3 className="w-5 h-5" />
|
||||
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>
|
||||
)}
|
||||
<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 className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
|
||||
<CardTitle className="text-base">Order Details</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>
|
||||
{/* Order Details Card */}
|
||||
<Card className="bg-white border border-slate-200 shadow-md">
|
||||
<CardHeader className="border-b border-slate-100">
|
||||
<CardTitle className="text-lg font-bold text-slate-900">Order Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-2 gap-6 text-sm">
|
||||
<div>
|
||||
<p className="text-slate-500">Hub</p>
|
||||
<p className="font-medium">{event.hub || "Hub Name"}</p>
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
|
||||
<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>
|
||||
<p className="text-slate-500">Name of Department</p>
|
||||
<p className="font-medium">Department name</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-50 rounded-lg flex items-center justify-center">
|
||||
<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 className="col-span-2">
|
||||
<p className="text-slate-500 mb-2">Order Addons</p>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline" className="text-xs">Title</Badge>
|
||||
<Badge variant="outline" className="text-xs">Travel Time</Badge>
|
||||
<Badge variant="outline" className="text-xs">Meal Provided</Badge>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-green-50 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
{shifts.length > 0 ? (
|
||||
shifts.map((shift, idx) => (
|
||||
<ShiftCard
|
||||
key={shift.id}
|
||||
shift={shift}
|
||||
onNotifyStaff={() => setShowNotifyDialog(true)}
|
||||
/>
|
||||
{/* Client Information (if not client viewing) */}
|
||||
{!isClient && (
|
||||
<Card className="bg-white border border-slate-200 shadow-md">
|
||||
<CardHeader className="border-b border-slate-100">
|
||||
<CardTitle className="text-lg font-bold text-slate-900">Client Information</CardTitle>
|
||||
</CardHeader>
|
||||
<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
|
||||
shift={{
|
||||
shift_name: "Shift 1",
|
||||
assigned_staff: event.assigned_staff || [],
|
||||
location: event.event_location,
|
||||
unpaid_break: 0,
|
||||
price: 23,
|
||||
amount: 120
|
||||
}}
|
||||
onNotifyStaff={() => setShowNotifyDialog(true)}
|
||||
/>
|
||||
<Card className="bg-white border border-slate-200">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Users className="w-12 h-12 mx-auto mb-4 text-slate-400" />
|
||||
<p className="text-slate-600 font-medium mb-2">No shifts defined for this event</p>
|
||||
<p className="text-slate-500 text-sm">Add roles and staff requirements to get started</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={showNotifyDialog} onOpenChange={setShowNotifyDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="w-12 h-12 bg-pink-500 rounded-full flex items-center justify-center text-white font-bold text-xl">
|
||||
L
|
||||
</div>
|
||||
</div>
|
||||
<DialogTitle className="text-center">Notification Name</DialogTitle>
|
||||
<p className="text-center text-sm text-slate-600">
|
||||
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>
|
||||
{/* Notes */}
|
||||
{event.notes && (
|
||||
<Card className="bg-white border border-slate-200 shadow-md">
|
||||
<CardHeader className="border-b border-slate-100">
|
||||
<CardTitle className="text-lg font-bold text-slate-900">Additional Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-slate-700 whitespace-pre-wrap">{event.notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ const statusColors = {
|
||||
'Overdue': 'bg-red-500 text-white',
|
||||
'Resolved': 'bg-blue-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',
|
||||
'Verified': 'bg-teal-500 text-white',
|
||||
'Pending': 'bg-amber-500 text-white',
|
||||
@@ -161,7 +161,7 @@ export default function Invoices() {
|
||||
<Button
|
||||
onClick={() => setShowPaymentDialog(true)}
|
||||
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
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
|
||||
import React from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
|
||||
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff,
|
||||
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical,
|
||||
Building2, Sparkles, CheckSquare, UserCheck, Store
|
||||
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -32,6 +31,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import ChatBubble from "@/components/chat/ChatBubble";
|
||||
import RoleSwitcher from "@/components/dev/RoleSwitcher";
|
||||
import NotificationPanel from "@/components/notifications/NotificationPanel";
|
||||
import { NotificationEngine } from "@/components/notifications/NotificationEngine";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
// Navigation items for each role
|
||||
@@ -44,7 +44,9 @@ const roleNavigationMap = {
|
||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||
{ title: "Onboarding", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||
{ title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign },
|
||||
@@ -57,13 +59,14 @@ const roleNavigationMap = {
|
||||
],
|
||||
procurement: [
|
||||
{ title: "Dashboard", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
|
||||
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
{ title: "Compliance", url: createPageUrl("WorkforceCompliance"), icon: Shield },
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Clipboard },
|
||||
{ title: "Rate Matrix", url: createPageUrl("VendorRateCard"), icon: DollarSign },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||
@@ -71,25 +74,27 @@ const roleNavigationMap = {
|
||||
],
|
||||
operator: [
|
||||
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
|
||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
],
|
||||
sector: [
|
||||
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
|
||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
@@ -101,6 +106,7 @@ const roleNavigationMap = {
|
||||
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
|
||||
{ title: "Compare Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
@@ -108,16 +114,17 @@ const roleNavigationMap = {
|
||||
],
|
||||
vendor: [
|
||||
{ title: "Dashboard", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
|
||||
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
|
||||
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
|
||||
{ title: "Schedule", url: createPageUrl("WorkforceShifts"), icon: Calendar },
|
||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||
{ title: "Team", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
{ title: "Compliance", url: createPageUrl("VendorCompliance"), icon: Shield },
|
||||
{ title: "Communications", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
{ title: "Leads", url: createPageUrl("Business"), icon: UserCheck },
|
||||
{ title: "Tasks", url: createPageUrl("ActivityLog"), icon: CheckSquare },
|
||||
{ title: "Business", url: createPageUrl("Business"), icon: Briefcase },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
{ title: "Audit Trail", url: createPageUrl("ActivityLog"), icon: Activity },
|
||||
@@ -125,8 +132,10 @@ const roleNavigationMap = {
|
||||
],
|
||||
workforce: [
|
||||
{ title: "Dashboard", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
|
||||
{ title: "Earnings", url: createPageUrl("WorkforceEarnings"), icon: DollarSign },
|
||||
@@ -281,200 +290,34 @@ export default function Layout({ children }) {
|
||||
--muted: 241 245 249;
|
||||
}
|
||||
|
||||
/* Calendar styling kept as is */
|
||||
.rdp * {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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_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;
|
||||
}
|
||||
.rdp * { border-color: transparent !important; }
|
||||
.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; }
|
||||
.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_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-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-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-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>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<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"
|
||||
/>
|
||||
<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" />
|
||||
</div>
|
||||
<h2 className="font-bold text-[#1C323E]">KROW</h2>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 bg-slate-50 p-3 rounded-lg">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={userAvatar} alt={userName} />
|
||||
<AvatarFallback className="bg-[#0A39DF] text-white font-bold">
|
||||
{userInitial}
|
||||
</AvatarFallback>
|
||||
<AvatarFallback className="bg-[#0A39DF] text-white font-bold">{userInitial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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)} />
|
||||
</div>
|
||||
<div className="p-3 border-t border-slate-200">
|
||||
<Button
|
||||
variant="ghost"
|
||||
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 variant="ghost" 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>
|
||||
</div>
|
||||
</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">
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
<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"
|
||||
/>
|
||||
<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" />
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<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="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<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"
|
||||
/>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<CloudOff className="w-5 h-5 group-hover:animate-pulse" />
|
||||
<span className="hidden lg:inline text-sm font-medium">Unpublished changes</span>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden hover:bg-slate-100"
|
||||
title="Search"
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="md:hidden hover:bg-slate-100" title="Search">
|
||||
<Search className="w-5 h-5 text-slate-600" />
|
||||
</Button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowNotifications(true)}
|
||||
className="relative p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Notifications"
|
||||
>
|
||||
<button 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" />
|
||||
{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">
|
||||
@@ -609,22 +420,21 @@ export default function Layout({ children }) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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")}>
|
||||
<SettingsIcon className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
<SettingsIcon className="w-4 h-4 mr-2" />Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Reports")}>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Reports
|
||||
<FileText className="w-4 h-4 mr-2" />Reports
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("ActivityLog")}>
|
||||
<Activity className="w-4 h-4 mr-2" />
|
||||
Activity Log
|
||||
<Activity className="w-4 h-4 mr-2" />Activity Log
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-red-600">
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Logout
|
||||
<LogOut className="w-4 h-4 mr-2" />Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</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)}`}>
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarImage src={userAvatar} alt={userName} />
|
||||
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white font-bold text-sm">
|
||||
{userInitial}
|
||||
</AvatarFallback>
|
||||
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white font-bold text-sm">{userInitial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden lg:block text-sm font-medium text-slate-700">{userName.split(' ')[0]}</span>
|
||||
</button>
|
||||
@@ -651,12 +459,10 @@ export default function Layout({ children }) {
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => window.location.href = getDashboardUrl(userRole)}>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
Dashboard
|
||||
<Home className="w-4 h-4 mr-2" />Dashboard
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("WorkforceProfile")}>
|
||||
<User className="w-4 h-4 mr-2" />
|
||||
My Profile
|
||||
<User className="w-4 h-4 mr-2" />My Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuContent>
|
||||
@@ -686,15 +492,11 @@ export default function Layout({ children }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotificationPanel
|
||||
isOpen={showNotifications}
|
||||
onClose={() => setShowNotifications(false)}
|
||||
/>
|
||||
|
||||
<NotificationPanel isOpen={showNotifications} onClose={() => setShowNotifications(false)} />
|
||||
<NotificationEngine />
|
||||
<ChatBubble />
|
||||
<RoleSwitcher />
|
||||
<Toaster />
|
||||
</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 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';
|
||||
|
||||
const PAGES = {
|
||||
@@ -244,6 +254,16 @@ const PAGES = {
|
||||
|
||||
VendorMarketplace: VendorMarketplace,
|
||||
|
||||
RapidOrder: RapidOrder,
|
||||
|
||||
SmartScheduler: SmartScheduler,
|
||||
|
||||
StaffOnboarding: StaffOnboarding,
|
||||
|
||||
NotificationSettings: NotificationSettings,
|
||||
|
||||
TaskBoard: TaskBoard,
|
||||
|
||||
}
|
||||
|
||||
function _getCurrentPage(url) {
|
||||
@@ -391,6 +411,16 @@ function PagesContent() {
|
||||
|
||||
<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>
|
||||
</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