Merge branch 'develop' into feature/ja-localization
9
.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
2
.github/workflows/build.yml
vendored
@@ -2,9 +2,7 @@ name: Build validation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "develop" ]
|
||||
pull_request:
|
||||
branches: [ "main", "develop" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
3
.gitignore
vendored
@@ -23,4 +23,5 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
resources/android/**/*
|
||||
resources/android/**/*
|
||||
resources/ios/**/*
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.donetick.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 24
|
||||
versionName "1.2.2"
|
||||
versionCode 25
|
||||
versionName "1.2.3"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -13,12 +13,16 @@ dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-device')
|
||||
implementation project(':capacitor-local-llm')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-network')
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capgo-capacitor-document-scanner')
|
||||
implementation project(':capgo-capacitor-nfc')
|
||||
implementation project(':capgo-capacitor-social-login')
|
||||
implementation project(':jcesarmobile-capacitor-ocr')
|
||||
implementation project(':revenuecat-purchases-capacitor')
|
||||
implementation project(':revenuecat-purchases-capacitor-ui')
|
||||
implementation project(':capacitor-plugin-safe-area')
|
||||
|
||||
37
android/app/release/output-metadata.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "com.donetick.app",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 20,
|
||||
"versionName": "1.0.20",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File",
|
||||
"baselineProfiles": [
|
||||
{
|
||||
"minApi": 28,
|
||||
"maxApi": 30,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/1/app-release.dm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"minApi": 31,
|
||||
"maxApi": 2147483647,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/0/app-release.dm"
|
||||
]
|
||||
}
|
||||
],
|
||||
"minSdkVersionForDexing": 24
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
@@ -20,13 +20,20 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Deep link intent filter for OAuth callback -->
|
||||
<!-- Deep link intent filter for OAuth and other donetick:// URLs -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="donetick" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- NFC NDEF dispatch: open app directly when a donetick:// tag is scanned -->
|
||||
<intent-filter>
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="donetick" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
@@ -36,6 +43,16 @@
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Push Notifications Icon -->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
|
||||
<!-- Push Notifications Color -->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_color"
|
||||
android:resource="@android:color/white" />
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
@@ -43,6 +60,8 @@
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
</manifest>
|
||||
|
||||
@@ -2,4 +2,36 @@ package com.donetick.app;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
import ee.forgr.capacitor.social.login.GoogleProvider;
|
||||
import ee.forgr.capacitor.social.login.SocialLoginPlugin;
|
||||
import ee.forgr.capacitor.social.login.ModifiedMainActivityForSocialLoginPlugin;
|
||||
import com.getcapacitor.PluginHandle;
|
||||
import com.getcapacitor.Plugin;
|
||||
import android.content.Intent;
|
||||
import android.util.Log;
|
||||
|
||||
|
||||
public class MainActivity extends BridgeActivity implements ModifiedMainActivityForSocialLoginPlugin {
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
|
||||
if (requestCode >= GoogleProvider.REQUEST_AUTHORIZE_GOOGLE_MIN && requestCode < GoogleProvider.REQUEST_AUTHORIZE_GOOGLE_MAX) {
|
||||
PluginHandle pluginHandle = getBridge().getPlugin("SocialLogin");
|
||||
if (pluginHandle == null) {
|
||||
Log.i("Google Activity Result", "SocialLogin login handle is null");
|
||||
return;
|
||||
}
|
||||
Plugin plugin = pluginHandle.getInstance();
|
||||
if (!(plugin instanceof SocialLoginPlugin)) {
|
||||
Log.i("Google Activity Result", "SocialLogin plugin instance is not SocialLoginPlugin");
|
||||
return;
|
||||
}
|
||||
((SocialLoginPlugin) plugin).handleGoogleLoginIntent(requestCode, data);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void IHaveModifiedTheMainActivityForTheUseWithSocialLoginPlugin() {}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.2.1'
|
||||
classpath 'com.google.gms:google-services:4.4.0'
|
||||
classpath 'com.android.tools.build:gradle:8.10.1'
|
||||
classpath 'com.google.gms:google-services:4.4.2'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
|
||||
@@ -14,6 +14,9 @@ project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/
|
||||
include ':capacitor-device'
|
||||
project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android')
|
||||
|
||||
include ':capacitor-local-llm'
|
||||
project(':capacitor-local-llm').projectDir = new File('../node_modules/@capacitor/local-llm/android')
|
||||
|
||||
include ':capacitor-local-notifications'
|
||||
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
||||
|
||||
@@ -29,9 +32,18 @@ project(':capacitor-push-notifications').projectDir = new File('../node_modules/
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
|
||||
include ':capgo-capacitor-document-scanner'
|
||||
project(':capgo-capacitor-document-scanner').projectDir = new File('../node_modules/@capgo/capacitor-document-scanner/android')
|
||||
|
||||
include ':capgo-capacitor-nfc'
|
||||
project(':capgo-capacitor-nfc').projectDir = new File('../node_modules/@capgo/capacitor-nfc/android')
|
||||
|
||||
include ':capgo-capacitor-social-login'
|
||||
project(':capgo-capacitor-social-login').projectDir = new File('../node_modules/@capgo/capacitor-social-login/android')
|
||||
|
||||
include ':jcesarmobile-capacitor-ocr'
|
||||
project(':jcesarmobile-capacitor-ocr').projectDir = new File('../node_modules/@jcesarmobile/capacitor-ocr/android')
|
||||
|
||||
include ':revenuecat-purchases-capacitor'
|
||||
project(':revenuecat-purchases-capacitor').projectDir = new File('../node_modules/@revenuecat/purchases-capacitor/android')
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
androidxActivityVersion = '1.9.2'
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.12.0'
|
||||
androidxFragmentVersion = '1.6.2'
|
||||
minSdkVersion = 28
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
androidxAppCompatVersion = '1.7.1'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
androidxCoreVersion = '1.17.0'
|
||||
androidxFragmentVersion = '1.8.9'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.9.0'
|
||||
androidxWebkitVersion = '1.14.0'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.1.5'
|
||||
androidxEspressoCoreVersion = '3.5.1'
|
||||
androidxJunitVersion = '1.3.0'
|
||||
androidxEspressoCoreVersion = '3.7.0'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
}
|
||||
firebaseMessagingVersion = '24.1.0'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
import type { CapacitorConfig } from '@capacitor/cli'
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.donetick.app',
|
||||
@@ -12,17 +12,17 @@ const config: CapacitorConfig = {
|
||||
presentationOptions: ['badge', 'sound', 'alert'],
|
||||
},
|
||||
LocalNotifications: {
|
||||
smallIcon: "ic_stat_icon_config_sample",
|
||||
iconColor: "#488AFF",
|
||||
sound: "beep.wav",
|
||||
smallIcon: 'ic_stat_icon_config_sample',
|
||||
iconColor: '#488AFF',
|
||||
sound: 'beep.wav',
|
||||
},
|
||||
GoogleAuth: {
|
||||
scopes: ['profile', 'email', 'openid'],
|
||||
clientId: process.env.VITE_APP_GOOGLE_CLIENT_ID,
|
||||
androidClientId: process.env.VITE_APP_ANDRIOD_CLIENT_ID,
|
||||
iosClientId: process.env.VITE_APP_IOS_CLIENT_ID,
|
||||
// GoogleAuth: {
|
||||
// scopes: ['profile', 'email', 'openid'],
|
||||
// clientId: process.env.VITE_APP_GOOGLE_CLIENT_ID,
|
||||
// androidClientId: process.env.VITE_APP_ANDRIOD_CLIENT_ID,
|
||||
// iosClientId: process.env.VITE_APP_IOS_CLIENT_ID,
|
||||
// },
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
export default config
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- <link rel="icon" type="image/svg+xml" href="/logo.svg" /> -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
|
||||
<title>Donetick</title>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
72FA9293C4A649D1BA0E5917 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 71866EB277374608AD02C137 /* PrivacyInfo.xcprivacy */; };
|
||||
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
|
||||
D1115B492C653D60004C6043 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = D1115B482C653D60004C6043 /* GoogleService-Info.plist */; };
|
||||
/* End PBXBuildFile section */
|
||||
@@ -28,6 +29,7 @@
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
71866EB277374608AD02C137 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
||||
D1115B482C653D60004C6043 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||
@@ -62,6 +64,7 @@
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
7F8756D8B27F46E3366F6CEA /* Pods */,
|
||||
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
|
||||
71866EB277374608AD02C137 /* PrivacyInfo.xcprivacy */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -127,8 +130,8 @@
|
||||
504EC2FC1FED79650016851F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 0920;
|
||||
LastUpgradeCheck = 0920;
|
||||
LastSwiftUpdateCheck = 920;
|
||||
LastUpgradeCheck = 920;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
@@ -169,6 +172,7 @@
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
D1115B492C653D60004C6043 /* GoogleService-Info.plist in Resources */,
|
||||
72FA9293C4A649D1BA0E5917 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -289,7 +293,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -340,7 +344,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
||||
@@ -355,13 +359,13 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||
MARKETING_VERSION = 1.2.2;
|
||||
MARKETING_VERSION = 1.2.3;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -375,12 +379,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.2.2;
|
||||
MARKETING_VERSION = 1.2.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.nfc.readersession.formats</key>
|
||||
<array>
|
||||
<string>TAG</string>
|
||||
</array>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
|
||||
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 53 KiB |
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
"images": [
|
||||
{
|
||||
"filename" : "AppIcon-512@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
"idiom": "universal",
|
||||
"size": "1024x1024",
|
||||
"filename": "AppIcon-512@2x.png",
|
||||
"platform": "ios"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,56 @@
|
||||
{
|
||||
"images" : [
|
||||
"images": [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-2.png",
|
||||
"scale" : "1x"
|
||||
"idiom": "universal",
|
||||
"filename": "Default@1x~universal~anyany.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-1.png",
|
||||
"scale" : "2x"
|
||||
"idiom": "universal",
|
||||
"filename": "Default@2x~universal~anyany.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732.png",
|
||||
"scale" : "3x"
|
||||
"idiom": "universal",
|
||||
"filename": "Default@3x~universal~anyany.png",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"idiom": "universal",
|
||||
"scale": "1x",
|
||||
"filename": "Default@1x~universal~anyany-dark.png"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"idiom": "universal",
|
||||
"scale": "2x",
|
||||
"filename": "Default@2x~universal~anyany-dark.png"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"idiom": "universal",
|
||||
"scale": "3x",
|
||||
"filename": "Default@3x~universal~anyany-dark.png"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
BIN
ios/App/App/Assets.xcassets/Splash.imageset/Default@1x~universal~anyany-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/Default@1x~universal~anyany.png
vendored
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/Default@2x~universal~anyany-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/Default@2x~universal~anyany.png
vendored
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/Default@3x~universal~anyany-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/Default@3x~universal~anyany.png
vendored
Normal file
|
After Width: | Height: | Size: 52 KiB |
@@ -2,10 +2,12 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NFCReaderUsageDescription</key>
|
||||
<string>Donetick uses NFC to read and write chore tags for quick task completion.</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>DoneTick</string>
|
||||
<string>Donetick</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -18,10 +20,27 @@
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.googleusercontent.apps.682262497914-5dacpk46qcc1494lood6ch8ul9c83kop</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>This app needs access to camera to take photos to attach to task or use as profile photo</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>This app needs access to photo library to select images to attach to task or use as profile photo</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
@@ -30,6 +49,7 @@
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
@@ -54,9 +74,5 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>This app needs access to camera to take photos to attach to task or use as profile photo</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>This app needs access to photo library to select images to attach to task or use as profile photo</string>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
||||
@@ -20,6 +20,7 @@ def capacitor_pods
|
||||
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
|
||||
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
|
||||
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
|
||||
pod 'CapgoCapacitorNfc', :path => '../../node_modules/@capgo/capacitor-nfc'
|
||||
pod 'CapgoCapacitorSocialLogin', :path => '../../node_modules/@capgo/capacitor-social-login'
|
||||
pod 'RevenuecatPurchasesCapacitor', :path => '../../node_modules/@revenuecat/purchases-capacitor'
|
||||
pod 'RevenuecatPurchasesCapacitorUi', :path => '../../node_modules/@revenuecat/purchases-capacitor-ui'
|
||||
|
||||
@@ -1,68 +1,76 @@
|
||||
PODS:
|
||||
- Alamofire (5.10.2)
|
||||
- AppAuth (2.0.0):
|
||||
- AppAuth/Core (= 2.0.0)
|
||||
- AppAuth/ExternalUserAgent (= 2.0.0)
|
||||
- AppAuth/Core (2.0.0)
|
||||
- AppAuth/ExternalUserAgent (2.0.0):
|
||||
- AppAuth (2.1.0):
|
||||
- AppAuth/Core (= 2.1.0)
|
||||
- AppAuth/ExternalUserAgent (= 2.1.0)
|
||||
- AppAuth/Core (2.1.0)
|
||||
- AppAuth/ExternalUserAgent (2.1.0):
|
||||
- AppAuth/Core
|
||||
- AppCheckCore (11.2.0):
|
||||
- AppCheckCore (11.3.0):
|
||||
- GoogleUtilities/Environment (~> 8.0)
|
||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- Capacitor (7.4.3):
|
||||
- PromisesSwift (~> 2.4)
|
||||
- RecaptchaInterop (~> 101.0)
|
||||
- Capacitor (8.4.1):
|
||||
- CapacitorCordova
|
||||
- CapacitorApp (7.0.2):
|
||||
- CapacitorApp (8.1.0):
|
||||
- Capacitor
|
||||
- CapacitorBrowser (7.0.2):
|
||||
- CapacitorBrowser (8.0.3):
|
||||
- Capacitor
|
||||
- CapacitorCommunitySqlite (7.0.1):
|
||||
- CapacitorCommunitySqlite (8.1.0):
|
||||
- Capacitor
|
||||
- SQLCipher
|
||||
- ZIPFoundation
|
||||
- CapacitorCordova (7.4.3)
|
||||
- CapacitorDevice (7.0.2):
|
||||
- CapacitorCordova (8.4.1)
|
||||
- CapacitorDevice (8.0.2):
|
||||
- Capacitor
|
||||
- CapacitorLocalNotifications (7.0.2):
|
||||
- CapacitorLocalLlm (1.0.0):
|
||||
- Capacitor
|
||||
- CapacitorNetwork (7.0.2):
|
||||
- CapacitorLocalNotifications (8.2.0):
|
||||
- Capacitor
|
||||
- CapacitorPluginSafeArea (4.0.0):
|
||||
- CapacitorNetwork (8.0.1):
|
||||
- Capacitor
|
||||
- CapacitorPreferences (7.0.2):
|
||||
- CapacitorPluginSafeArea (5.0.0):
|
||||
- Capacitor
|
||||
- CapacitorPushNotifications (7.0.2):
|
||||
- CapacitorPreferences (8.0.1):
|
||||
- Capacitor
|
||||
- CapacitorStatusBar (7.0.2):
|
||||
- CapacitorPushNotifications (8.1.1):
|
||||
- Capacitor
|
||||
- CapgoCapacitorSocialLogin (7.11.2):
|
||||
- CapacitorStatusBar (8.0.2):
|
||||
- Capacitor
|
||||
- CapgoCapacitorDocumentScanner (8.4.0):
|
||||
- Capacitor
|
||||
- CapgoCapacitorNfc (8.1.7):
|
||||
- Capacitor
|
||||
- CapgoCapacitorSocialLogin (8.3.34):
|
||||
- Alamofire (~> 5.10.2)
|
||||
- Capacitor
|
||||
- FBSDKCoreKit (= 18.0.0)
|
||||
- FBSDKLoginKit (= 18.0.0)
|
||||
- FBSDKCoreKit (~> 18.0)
|
||||
- FBSDKLoginKit (~> 18.0)
|
||||
- GoogleSignIn (~> 9.0.0)
|
||||
- FBAEMKit (18.0.0):
|
||||
- FBSDKCoreKit_Basics (= 18.0.0)
|
||||
- FBSDKCoreKit (18.0.0):
|
||||
- FBAEMKit (= 18.0.0)
|
||||
- FBSDKCoreKit_Basics (= 18.0.0)
|
||||
- FBSDKCoreKit_Basics (18.0.0)
|
||||
- FBSDKLoginKit (18.0.0):
|
||||
- FBSDKCoreKit (= 18.0.0)
|
||||
- FirebaseCore (12.3.0):
|
||||
- FirebaseCoreInternal (~> 12.3.0)
|
||||
- FBAEMKit (18.1.0):
|
||||
- FBSDKCoreKit_Basics (= 18.1.0)
|
||||
- FBSDKCoreKit (18.1.0):
|
||||
- FBAEMKit (= 18.1.0)
|
||||
- FBSDKCoreKit_Basics (= 18.1.0)
|
||||
- FBSDKCoreKit_Basics (18.1.0)
|
||||
- FBSDKLoginKit (18.1.0):
|
||||
- FBSDKCoreKit (= 18.1.0)
|
||||
- FirebaseCore (12.15.0):
|
||||
- FirebaseCoreInternal (~> 12.15.0)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- GoogleUtilities/Logger (~> 8.1)
|
||||
- FirebaseCoreInternal (12.3.0):
|
||||
- FirebaseCoreInternal (12.15.0):
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||
- FirebaseInstallations (12.3.0):
|
||||
- FirebaseCore (~> 12.3.0)
|
||||
- FirebaseInstallations (12.15.0):
|
||||
- FirebaseCore (~> 12.15.0)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- GoogleUtilities/UserDefaults (~> 8.1)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- FirebaseMessaging (12.3.0):
|
||||
- FirebaseCore (~> 12.3.0)
|
||||
- FirebaseInstallations (~> 12.3.0)
|
||||
- FirebaseMessaging (12.15.0):
|
||||
- FirebaseCore (~> 12.15.0)
|
||||
- FirebaseInstallations (~> 12.15.0)
|
||||
- GoogleDataTransport (~> 10.1)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
@@ -77,60 +85,65 @@ PODS:
|
||||
- AppCheckCore (~> 11.0)
|
||||
- GTMAppAuth (~> 5.0)
|
||||
- GTMSessionFetcher/Core (~> 3.3)
|
||||
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
|
||||
- GoogleUtilities/AppDelegateSwizzler (8.1.2):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Network
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Environment (8.1.0):
|
||||
- GoogleUtilities/Environment (8.1.2):
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Logger (8.1.0):
|
||||
- GoogleUtilities/Logger (8.1.2):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Network (8.1.0):
|
||||
- GoogleUtilities/Network (8.1.2):
|
||||
- GoogleUtilities/Logger
|
||||
- "GoogleUtilities/NSData+zlib"
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Reachability
|
||||
- "GoogleUtilities/NSData+zlib (8.1.0)":
|
||||
- "GoogleUtilities/NSData+zlib (8.1.2)":
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Privacy (8.1.0)
|
||||
- GoogleUtilities/Reachability (8.1.0):
|
||||
- GoogleUtilities/Privacy (8.1.2)
|
||||
- GoogleUtilities/Reachability (8.1.2):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/UserDefaults (8.1.0):
|
||||
- GoogleUtilities/UserDefaults (8.1.2):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GTMAppAuth (5.0.0):
|
||||
- AppAuth/Core (~> 2.0)
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3)
|
||||
- GTMSessionFetcher/Core (3.5.0)
|
||||
- JcesarmobileCapacitorOcr (0.3.0):
|
||||
- Capacitor
|
||||
- nanopb (3.30910.0):
|
||||
- nanopb/decode (= 3.30910.0)
|
||||
- nanopb/encode (= 3.30910.0)
|
||||
- nanopb/decode (3.30910.0)
|
||||
- nanopb/encode (3.30910.0)
|
||||
- PromisesObjC (2.4.0)
|
||||
- PurchasesHybridCommon (17.0.0):
|
||||
- RevenueCat (= 5.35.1)
|
||||
- PurchasesHybridCommonUI (17.0.0):
|
||||
- PurchasesHybridCommon (= 17.0.0)
|
||||
- RevenueCatUI (= 5.35.1)
|
||||
- RevenueCat (5.35.1)
|
||||
- RevenuecatPurchasesCapacitor (11.1.2):
|
||||
- PromisesObjC (2.4.1)
|
||||
- PromisesSwift (2.4.1):
|
||||
- PromisesObjC (= 2.4.1)
|
||||
- PurchasesHybridCommon (17.55.1):
|
||||
- RevenueCat (= 5.67.1)
|
||||
- PurchasesHybridCommonUI (17.55.1):
|
||||
- PurchasesHybridCommon (= 17.55.1)
|
||||
- RevenueCatUI (= 5.67.1)
|
||||
- RecaptchaInterop (101.0.0)
|
||||
- RevenueCat (5.67.1)
|
||||
- RevenuecatPurchasesCapacitor (12.3.2):
|
||||
- Capacitor
|
||||
- PurchasesHybridCommon (= 17.0.0)
|
||||
- RevenuecatPurchasesCapacitorUi (11.1.2):
|
||||
- PurchasesHybridCommon (= 17.55.1)
|
||||
- RevenuecatPurchasesCapacitorUi (12.3.2):
|
||||
- Capacitor
|
||||
- PurchasesHybridCommonUI (= 17.0.0)
|
||||
- RevenueCatUI (5.35.1):
|
||||
- RevenueCat (= 5.35.1)
|
||||
- PurchasesHybridCommonUI (= 17.55.1)
|
||||
- RevenueCatUI (5.67.1):
|
||||
- RevenueCat (= 5.67.1)
|
||||
- SQLCipher (4.10.0):
|
||||
- SQLCipher/standard (= 4.10.0)
|
||||
- SQLCipher/common (4.10.0)
|
||||
- SQLCipher/standard (4.10.0):
|
||||
- SQLCipher/common
|
||||
- ZIPFoundation (0.9.19)
|
||||
- ZIPFoundation (0.9.20)
|
||||
|
||||
DEPENDENCIES:
|
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||
@@ -139,14 +152,18 @@ DEPENDENCIES:
|
||||
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
|
||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorDevice (from `../../node_modules/@capacitor/device`)"
|
||||
- "CapacitorLocalLlm (from `../../node_modules/@capacitor/local-llm`)"
|
||||
- "CapacitorLocalNotifications (from `../../node_modules/@capacitor/local-notifications`)"
|
||||
- "CapacitorNetwork (from `../../node_modules/@capacitor/network`)"
|
||||
- CapacitorPluginSafeArea (from `../../node_modules/capacitor-plugin-safe-area`)
|
||||
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
|
||||
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
|
||||
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
|
||||
- "CapgoCapacitorDocumentScanner (from `../../node_modules/@capgo/capacitor-document-scanner`)"
|
||||
- "CapgoCapacitorNfc (from `../../node_modules/@capgo/capacitor-nfc`)"
|
||||
- "CapgoCapacitorSocialLogin (from `../../node_modules/@capgo/capacitor-social-login`)"
|
||||
- FirebaseMessaging
|
||||
- "JcesarmobileCapacitorOcr (from `../../node_modules/@jcesarmobile/capacitor-ocr`)"
|
||||
- "RevenuecatPurchasesCapacitor (from `../../node_modules/@revenuecat/purchases-capacitor`)"
|
||||
- "RevenuecatPurchasesCapacitorUi (from `../../node_modules/@revenuecat/purchases-capacitor-ui`)"
|
||||
|
||||
@@ -170,8 +187,10 @@ SPEC REPOS:
|
||||
- GTMSessionFetcher
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
- PromisesSwift
|
||||
- PurchasesHybridCommon
|
||||
- PurchasesHybridCommonUI
|
||||
- RecaptchaInterop
|
||||
- RevenueCat
|
||||
- RevenueCatUI
|
||||
- SQLCipher
|
||||
@@ -190,6 +209,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/@capacitor/ios"
|
||||
CapacitorDevice:
|
||||
:path: "../../node_modules/@capacitor/device"
|
||||
CapacitorLocalLlm:
|
||||
:path: "../../node_modules/@capacitor/local-llm"
|
||||
CapacitorLocalNotifications:
|
||||
:path: "../../node_modules/@capacitor/local-notifications"
|
||||
CapacitorNetwork:
|
||||
@@ -202,8 +223,14 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/@capacitor/push-notifications"
|
||||
CapacitorStatusBar:
|
||||
:path: "../../node_modules/@capacitor/status-bar"
|
||||
CapgoCapacitorDocumentScanner:
|
||||
:path: "../../node_modules/@capgo/capacitor-document-scanner"
|
||||
CapgoCapacitorNfc:
|
||||
:path: "../../node_modules/@capgo/capacitor-nfc"
|
||||
CapgoCapacitorSocialLogin:
|
||||
:path: "../../node_modules/@capgo/capacitor-social-login"
|
||||
JcesarmobileCapacitorOcr:
|
||||
:path: "../../node_modules/@jcesarmobile/capacitor-ocr"
|
||||
RevenuecatPurchasesCapacitor:
|
||||
:path: "../../node_modules/@revenuecat/purchases-capacitor"
|
||||
RevenuecatPurchasesCapacitorUi:
|
||||
@@ -211,45 +238,51 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
|
||||
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
|
||||
Capacitor: 28d6c01026a9a3f7156529498ec1f389a2a28dbc
|
||||
CapacitorApp: 1f6922c9c5c8b1c538d7fbe92ebe44a81b34bed3
|
||||
CapacitorBrowser: 22541e48442de44dc629c214388290d6eecc6ae9
|
||||
CapacitorCommunitySqlite: 8b2c6bab33e3519280811d481f8bd0fa90343e1b
|
||||
CapacitorCordova: 435121e81a2df4d0034f0fb11fcefab5104cfdb5
|
||||
CapacitorDevice: 81ae78d5d1942707caad79276badd458bf6ec603
|
||||
CapacitorLocalNotifications: 665188ae8accd40806129073896fb2b39322d858
|
||||
CapacitorNetwork: 695069886b3c5ed514db69aa3d026b8dc3c03a6b
|
||||
CapacitorPluginSafeArea: 22031c3436269ca80fac90ec2c94bc7c1e59a81d
|
||||
CapacitorPreferences: 65107ed7437d96ee72583df5763985e3c0ff2bc2
|
||||
CapacitorPushNotifications: 7c0659b349b149ee3936a682da31a9d269de9582
|
||||
CapacitorStatusBar: e04d05e121d5a5979c29eb4249186a4e4a84cacb
|
||||
CapgoCapacitorSocialLogin: a320106e1d032da88576cf8b31a336e971f5acad
|
||||
FBAEMKit: e34530df538b8eb8aeb53c35867715ba6c63ef0c
|
||||
FBSDKCoreKit: d3f479a69127acebb1c6aad91c1a33907bcf6c2f
|
||||
FBSDKCoreKit_Basics: 017b6dc2a1862024815a8229e75661e627ac1e29
|
||||
FBSDKLoginKit: 5875762d1fe09ddcb05d03365d4f5dc34413843d
|
||||
FirebaseCore: ff47fe1ad3ab9ef66edd3e8bc4647b493d2067f8
|
||||
FirebaseCoreInternal: a9e1ff270f217489d9258563b693d11a312903bf
|
||||
FirebaseInstallations: ca48ec60ea51b66b9f214a91847ea3720cde97f5
|
||||
FirebaseMessaging: 919ce76cb353f0c36d463f5461d8ab584e6ed765
|
||||
AppAuth: ef4da5a3fc2e10b90c09a0a94a9baeaedc0341d5
|
||||
AppCheckCore: 214137f5c378d1dec88a68425c467fe65aaff637
|
||||
Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b
|
||||
CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650
|
||||
CapacitorBrowser: c987c73d09d8bd3b5ec13f06338b1e14d5d2be69
|
||||
CapacitorCommunitySqlite: eac6acfb852f46e7988fc59604d7f900498d354e
|
||||
CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09
|
||||
CapacitorDevice: 14cba6f88d1c3074cbf825fea977c8c526453ff8
|
||||
CapacitorLocalLlm: a05516151a02923a9e7dae9949d3817e85e321f0
|
||||
CapacitorLocalNotifications: 2615aa008f608b95d3921a778ee1988abf1e6148
|
||||
CapacitorNetwork: 8812ce60d11fb63d8f2e4ba51a49b2e59892ebe2
|
||||
CapacitorPluginSafeArea: 9d0682951c011bf0b36e513a89103c5fbff7ac49
|
||||
CapacitorPreferences: cca2021f386efb75947c850334447d9ff22b14f1
|
||||
CapacitorPushNotifications: ec08d589c226a2c0db7c032ec1bf5b044ec85f8e
|
||||
CapacitorStatusBar: 01d5763b4ed720de5ce2edbc938de6a98f4c8f32
|
||||
CapgoCapacitorDocumentScanner: 7ad9e8ed9c054d551bfc948660d995b9545723d8
|
||||
CapgoCapacitorNfc: 5bf5a951ca231d7c40f1ac20549d2abcd1b8f939
|
||||
CapgoCapacitorSocialLogin: b0e67630b9d9b5f14fff774c825c1b8fa85eff7b
|
||||
FBAEMKit: 64088ff0380f50e4a56e2ee07d984f7f1aef7595
|
||||
FBSDKCoreKit: 8390ea3a8fac186f444275f31d7512e760b47cba
|
||||
FBSDKCoreKit_Basics: 3a0005c78b355388bf2df5c6dbe6381b77ea4be3
|
||||
FBSDKLoginKit: d8e2711a3a03b0026703735c8d28a2b5a0e1f4fd
|
||||
FirebaseCore: 2e86a4ea1684d4381707069e4a6d89ac808e901e
|
||||
FirebaseCoreInternal: 6ab6a02c94446c026d2cf35cf5383842ebaa4992
|
||||
FirebaseInstallations: eb29ccbf64eaedf86fd5b2ccc7fabde567660b52
|
||||
FirebaseMessaging: 40017d7bc8457ee295b0f41d480a80fdabc9994e
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleSignIn: c7f09cfbc85a1abf69187be091997c317cc33b77
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850
|
||||
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
|
||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||
JcesarmobileCapacitorOcr: d3668e104d7ad8968a9d00fd4766e18fea0fe305
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
PurchasesHybridCommon: 34d2f73712f4c02f2963fcfb62d43757a9136860
|
||||
PurchasesHybridCommonUI: 16e162423c0010f9a6f2c1d6505dcc075f49dcbe
|
||||
RevenueCat: 47d52620419dcf3dce4e724eade9d390e16ab4b0
|
||||
RevenuecatPurchasesCapacitor: 51cd81cec7db693a2552b45c36261d849e1cf574
|
||||
RevenuecatPurchasesCapacitorUi: 267758d61aba42a0a3f035e6ba3c4630112357e4
|
||||
RevenueCatUI: 3c72ef61b54c3f4787afab6a3b0f00cbec192063
|
||||
PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273
|
||||
PromisesSwift: 217dea0fd5d2ad65222a109c48698add13cc1c5b
|
||||
PurchasesHybridCommon: ecd9d7c586287625f1225b8de5e24c1021502270
|
||||
PurchasesHybridCommonUI: 3056a3d261529f6adb8f74ecc3ab7f1cc0d3fab4
|
||||
RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba
|
||||
RevenueCat: 9357239a9f0978285e68983637b8a602e65c94f0
|
||||
RevenuecatPurchasesCapacitor: c299be01b1a5c1d5eb118c30eadc84160944ecf2
|
||||
RevenuecatPurchasesCapacitorUi: dc2ec0d97638ebe4ac0078452f5c212b1525ea1c
|
||||
RevenueCatUI: 6f9291da540f98baf405b5bdfc46b2d5547b704a
|
||||
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
||||
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: bb6dcef70c8edc058fe3ba311f08a223fd7dbc66
|
||||
PODFILE CHECKSUM: 21b805bdbbb6ac4b8a3527ee8ef346ba0a55ef65
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
29
ios/App/PrivacyInfo.xcprivacy
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>85F4.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
0
ios/Podfile
Normal file
2959
package-lock.json
generated
50
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"private": true,
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.3",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
@@ -30,43 +30,49 @@
|
||||
"ionic:build": "npm run build",
|
||||
"ionic:serve": "npm run start",
|
||||
"android": "npm run build && npx cap sync android",
|
||||
"cap": "npm run build && npx cap sync",
|
||||
"bump": "node bump-version.js",
|
||||
"bump:minor": "node bump-version.js minor",
|
||||
"bump:major": "node bump-version.js major"
|
||||
"bump:major": "node bump-version.js major",
|
||||
"bump:patch": "node bump-version.js patch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/sqlite": "^7.0.0",
|
||||
"@capacitor/android": "^7.0.0",
|
||||
"@capacitor/app": "^7.0.0",
|
||||
"@capacitor/browser": "^7.0.2",
|
||||
"@capacitor/core": "^7.0.0",
|
||||
"@capacitor/device": "^7.0.0",
|
||||
"@capacitor/ios": "^7.0.0",
|
||||
"@capacitor/local-notifications": "^7.0.0",
|
||||
"@capacitor/network": "^7.0.1",
|
||||
"@capacitor/preferences": "^7.0.0",
|
||||
"@capacitor/push-notifications": "^7.0.0",
|
||||
"@capacitor/status-bar": "^7.0.0",
|
||||
"@capgo/capacitor-social-login": "^7.5.3",
|
||||
"@capacitor-community/sqlite": "^8.0.0",
|
||||
"@capacitor/android": "^8.0.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/browser": "^8.0.0",
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"@capacitor/device": "^8.0.0",
|
||||
"@capacitor/ios": "^8.0.0",
|
||||
"@capacitor/local-llm": "^1.0.0",
|
||||
"@capacitor/local-notifications": "^8.0.0",
|
||||
"@capacitor/network": "^8.0.0",
|
||||
"@capacitor/preferences": "^8.0.0",
|
||||
"@capacitor/push-notifications": "^8.0.0",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@capgo/capacitor-document-scanner": "^8.4.0",
|
||||
"@capgo/capacitor-nfc": "^8.0.0",
|
||||
"@capgo/capacitor-social-login": "^8.0.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@jcesarmobile/capacitor-ocr": "^0.3.0",
|
||||
"@meauxt/react-swipeable-list": "^1.0.0",
|
||||
"@mui/icons-material": "^5.16.13",
|
||||
"@mui/joy": "^5.0.0-beta.20",
|
||||
"@mui/material": "^5.15.2",
|
||||
"@openreplay/tracker": "^14.0.4",
|
||||
"@revenuecat/purchases-capacitor": "^11.1.0",
|
||||
"@revenuecat/purchases-capacitor-ui": "^11.1.0",
|
||||
"@revenuecat/purchases-capacitor": "^12.0.0",
|
||||
"@revenuecat/purchases-capacitor-ui": "^12.0.0",
|
||||
"@swc/core": "^1.12.5",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"aos": "^2.3.4",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"caniuse-lite": "^1.0.30001769",
|
||||
"capacitor-plugin-safe-area": "^4.0.0",
|
||||
"capacitor-plugin-safe-area": "^5.0.0",
|
||||
"chrono-node": "^2.7.7",
|
||||
"dotenv": "^16.4.5",
|
||||
"esm": "^3.2.25",
|
||||
@@ -92,11 +98,12 @@
|
||||
"reactjs-social-login": "^2.6.3",
|
||||
"recharts": "^2.15.0",
|
||||
"reusify": "^1.0.4",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"vite-plugin-pwa": "^0.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@capacitor/cli": "^7.0.0",
|
||||
"@capacitor/cli": "^8.0.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.14.6",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
@@ -104,6 +111,7 @@
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"capacitor-set-version": "^2.2.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.2",
|
||||
@@ -119,6 +127,10 @@
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-tailwindcss": "^0.5.10",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^5.2.13"
|
||||
},
|
||||
"allowScripts": {
|
||||
"tesseract.js@7.0.0": true
|
||||
}
|
||||
}
|
||||
|
||||
75
public/locales/de/chores.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"title": "Aufgaben",
|
||||
"myChores": "Meine Aufgaben",
|
||||
"allChores": "Alle Aufgaben",
|
||||
"addChore": "Aufgabe hinzufügen",
|
||||
"editChore": "Aufgabe bearbeiten",
|
||||
"deleteChore": "Aufgabe löschen",
|
||||
"completeChore": "Aufgabe abschließen",
|
||||
"dueDate": "Fälligkeitsdatum",
|
||||
"assignedTo": "Zugewiesen an",
|
||||
"priority": "Priorität",
|
||||
"status": "Status",
|
||||
"description": "Beschreibung",
|
||||
"choreView": {
|
||||
"assignment": "Zuweisung",
|
||||
"assigned": "Zugewiesen",
|
||||
"last": "Letzte",
|
||||
"schedule": "Zeitplan",
|
||||
"due": "Fällig",
|
||||
"statistics": "Statistiken",
|
||||
"completed": "Abgeschlossen",
|
||||
"times": "mal",
|
||||
"details": "Details",
|
||||
"createdBy": "Erstellt von",
|
||||
"na": "Nicht verfügbar",
|
||||
"taskCompleted": "Aufgabe abgeschlossen",
|
||||
"taskCompletedMessage": "Deine Aufgabe wurde als abgeschlossen markiert",
|
||||
"taskCompletionUndone": "Aufgabenabschluss wurde rückgängig gemacht.",
|
||||
"taskSkipUndone": "Aufgabe überspringen wurde rückgängig gemacht.",
|
||||
"undoSuccessful": "Rückgängig erfolgreich",
|
||||
"undoFailed": "Rückgängig fehlgeschlagen",
|
||||
"undoFailedMessage": "Die Aktion konnte nicht rückgängig gemacht werden. Bitte versuche es erneut.",
|
||||
"resetTimer": "Timer zurücksetzen",
|
||||
"resetTimerConfirmation": "Bist du sicher, dass du den Timer zurücksetzen möchtest? Dies löscht alle Zeitaufzeichnungen seit du die Aufgabe gestartet hast.",
|
||||
"clearAllTimeRecords": "Alle Zeitaufzeichnungen löschen",
|
||||
"clearAllTimeConfirmation": "Dies löscht dauerhaft alle Timer für diese Aufgabe und setzt sie zurück auf \"nicht gestartet\".",
|
||||
"descriptionTitle": "Beschreibung",
|
||||
"description": "Beschreibung:",
|
||||
"previousNote": "Vorherige Notiz",
|
||||
"previousNoteLabel": "Vorherige Notiz:",
|
||||
"subtasksLabel": "Unteraufgaben:",
|
||||
"taskActions": "Aufgabenaktionen",
|
||||
"addNote": "Notiz hinzufügen",
|
||||
"additionalNotes": "Zusätzliche Notizen:",
|
||||
"notePlaceholder": "Notiz zur Fertigstellung hinzufügen...",
|
||||
"setCustomCompletionTime": "Benutzerdefinierte Abschlusszeit festlegen",
|
||||
"skipTask": "Aufgabe überspringen",
|
||||
"skipTaskConfirmation": "Bist du sicher, dass du diese Aufgabe überspringen möchtest?",
|
||||
"markComplete": "Als abgeschlossen markieren",
|
||||
"markAsDone": "Als erledigt markieren",
|
||||
"edit": "Bearbeiten",
|
||||
"archive": "Archivieren",
|
||||
"unarchive": "Archivierung aufheben",
|
||||
"viewHistory": "Verlauf anzeigen",
|
||||
"history": "Verlauf",
|
||||
"startTimer": "Timer starten",
|
||||
"start": "Starten",
|
||||
"pauseTimer": "Timer pausieren",
|
||||
"approve": "Genehmigen",
|
||||
"reject": "Ablehnen",
|
||||
"pendingApproval": "Genehmigung ausstehend",
|
||||
"undo": "Rückgängig",
|
||||
"skip": "Überspringen",
|
||||
"cancel": "Abbrechen",
|
||||
"noPriority": "Keine Priorität",
|
||||
"subtasks": "Unteraufgaben",
|
||||
"noDescription": "Keine Beschreibung verfügbar",
|
||||
"timer": {
|
||||
"active": "Timer aktiv",
|
||||
"paused": "Timer pausiert",
|
||||
"reset": "Timer zurücksetzen",
|
||||
"delete": "Sitzung löschen"
|
||||
}
|
||||
}
|
||||
}
|
||||
33
public/locales/de/common.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"close": "Schließen",
|
||||
"confirm": "Bestätigen",
|
||||
"loading": "Laden...",
|
||||
"error": "Fehler",
|
||||
"success": "Erfolg",
|
||||
"warning": "Warnung",
|
||||
"refresh": "Aktualisieren",
|
||||
"copy": "Kopieren",
|
||||
"copied": "Kopiert!",
|
||||
"settings": "Einstellungen",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"back": "Zurück",
|
||||
"backToCalendar": "Zurück zum Kalender",
|
||||
"logout": "Abmelden",
|
||||
"version": "Version",
|
||||
"navigation": {
|
||||
"allTasks": "Alle Aufgaben",
|
||||
"archived": "Archiviert",
|
||||
"things": "Things",
|
||||
"labels": "Beschriftungen",
|
||||
"projects": "Projekte",
|
||||
"filters": "Filter",
|
||||
"activities": "Aktivitäten",
|
||||
"points": "Punkte",
|
||||
"settings": "Einstellungen"
|
||||
}
|
||||
}
|
||||
174
public/locales/de/settings.json
Normal file
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"title": "Einstellungen",
|
||||
"circleSettings": {
|
||||
"title": "Kreis-Einstellungen",
|
||||
"description": "Dein Account wird automatisch mit einem Kreis verbunden, wenn du einen erstellst oder beitrittst. Lade einfach Freunde ein, indem du den einzigartigen Kreis-Code oder Link unten teilst. Du erhältst eine Benachrichtigung, wenn jemand deinem Kreis beitreten möchte. Wenn du gehen möchtest, klicke einfach auf 'Kreis verlassen'.",
|
||||
"circleCode": "Kreis-Code",
|
||||
"copyCode": "Code kopieren",
|
||||
"copyLink": "Link kopieren",
|
||||
"codeCopied": "Kreis-Code kopiert!",
|
||||
"linkCopied": "Kreis-Link kopiert!",
|
||||
"joinCircle": "Einem Kreis beitreten",
|
||||
"joinCirclePlaceholder": "Kreis-Code eingeben",
|
||||
"join": "Beitreten",
|
||||
"leave": "Kreis verlassen",
|
||||
"leaveConfirmTitle": "Kreis verlassen",
|
||||
"leaveConfirmMessage": "Bist du sicher, dass du diesen Kreis verlassen möchtest?",
|
||||
"circleMembers": "Kreis-Mitglieder",
|
||||
"circleMemberRequests": "Kreis-Beitrittsanfragen",
|
||||
"admin": "Administrator",
|
||||
"member": "Mitglied",
|
||||
"pending": "Ausstehend",
|
||||
"accept": "Akzeptieren",
|
||||
"reject": "Ablehnen",
|
||||
"makeAdmin": "Zum Administrator machen",
|
||||
"makeMember": "Zum Mitglied machen",
|
||||
"remove": "Entfernen",
|
||||
"webhookURL": "Webhook-URL",
|
||||
"webhookDescription": "Gib eine Webhook-URL ein, um Benachrichtigungen für Kreis-Ereignisse zu erhalten",
|
||||
"webhookPlaceholder": "https://deine-webhook-url.com"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "Account-Einstellungen",
|
||||
"subscription": "Abonnement",
|
||||
"subscriptionStatus": "Aktueller Plan",
|
||||
"free": "Kostenlos",
|
||||
"plus": "Plus",
|
||||
"upgrade": "Upgraden",
|
||||
"cancel": "Kündigen",
|
||||
"changePassword": "Passwort ändern",
|
||||
"password": "Passwort",
|
||||
"dangerZone": "Gefahrenbereich",
|
||||
"dangerZoneDescription": "Sobald du dein Konto löschst, gibt es kein Zurück mehr. Bitte sei dir sicher.",
|
||||
"deleteAccount": "Account löschen"
|
||||
},
|
||||
"localization": {
|
||||
"title": "Sprach- und Formateinstellungen",
|
||||
"description": "Passe Sprache, Datumsformat und regionale Einstellungen für dein Konto an.",
|
||||
"language": "Sprache",
|
||||
"languageDescription": "Wähle deine bevorzugte Sprache",
|
||||
"dateFormat": "Datumsformat",
|
||||
"dateFormatDescription": "Wähle, wie Daten in der gesamten Anwendung angezeigt werden sollen",
|
||||
"timeFormat": "Zeitformat",
|
||||
"timeFormatDescription": "Wähle 12-Stunden- oder 24-Stunden-Zeitformat",
|
||||
"12hour": "12-Stunden (AM/PM)",
|
||||
"24hour": "24-Stunden",
|
||||
"firstDayOfWeek": "Erster Tag der Woche",
|
||||
"firstDayOfWeekDescription": "Wähle, welcher Tag deine Woche beginnt",
|
||||
"sunday": "Sonntag",
|
||||
"monday": "Montag",
|
||||
"saturday": "Samstag",
|
||||
"formats": {
|
||||
"mdy": "MM/TT/JJJJ (USA)",
|
||||
"dmy": "TT/MM/JJJJ (Europa)",
|
||||
"ymd": "JJJJ-MM-TT (ISO)",
|
||||
"long": "Langes Format (z.B. 1. Januar 2024)",
|
||||
"short": "Kurzes Format (z.B. 1. Jan 2024)"
|
||||
}
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Seitenleisten-Anpassung",
|
||||
"description": "Passe das Layout und die Sichtbarkeit von Karten in der Seitenleiste an. Dieser Bereich ist nur auf großen Bildschirmgeräten wie Tablets und Desktops verfügbar."
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme-Einstellungen",
|
||||
"description": "Wähle, wie die Seite für dich aussieht. Wähle ein einzelnes Theme oder synchronisiere mit deinem System und wechsle automatisch zwischen Tag- und Nacht-Themes.",
|
||||
"themeMode": "Theme-Modus",
|
||||
"light": "Hell",
|
||||
"dark": "Dunkel",
|
||||
"system": "System"
|
||||
},
|
||||
"notifications": {
|
||||
"settingsSaved": "Einstellungen erfolgreich gespeichert",
|
||||
"settingsSaveFailed": "Speichern der Einstellungen fehlgeschlagen",
|
||||
"invalidWebhook": "Ungültige Webhook-URL"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profil-Einstellungen",
|
||||
"description": "Aktualisiere deinen Anzeigenamen und dein Profilbild.",
|
||||
"photoUpdated": "Foto aktualisiert",
|
||||
"photoUpdatedMessage": "Dein Profilbild wurde erfolgreich aktualisiert!",
|
||||
"uploadFailed": "Upload fehlgeschlagen",
|
||||
"uploadFailedMessage": "Das Hochladen deines Fotos ist fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"profileUpdated": "Profil aktualisiert",
|
||||
"profileUpdatedMessage": "Deine Profilinformationen wurden erfolgreich gespeichert!",
|
||||
"updateFailed": "Update fehlgeschlagen",
|
||||
"updateFailedMessage": "Dein Profil konnte nicht aktualisiert werden. Bitte überprüfe deine Verbindung und versuche es erneut.",
|
||||
"changePhoto": "Foto ändern",
|
||||
"displayName": "Anzeigename",
|
||||
"displayNamePlaceholder": "Gib deinen Anzeigenamen ein",
|
||||
"timezone": "Zeitzone",
|
||||
"timezonePlaceholder": "Wähle deine Zeitzone",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Einstellungen",
|
||||
"subtitle": "Passe deine Erfahrung an und verwalte deine Account-Einstellungen",
|
||||
"upgrade": {
|
||||
"title": "Auf Plus upgraden",
|
||||
"description": "Schalte mächtige Funktionen frei, um deine Produktivität zu steigern",
|
||||
"button": "Jetzt upgraden",
|
||||
"features": {
|
||||
"richText": "Rich-Text-Beschreibungen",
|
||||
"notifications": "Aufgaben-Benachrichtigungen",
|
||||
"apiIntegrations": "API-Integrationen",
|
||||
"advancedAutomation": "Erweiterte Automatisierung"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"title": "Profil-Einstellungen",
|
||||
"description": "Aktualisiere deine Profilinformationen, Foto, Anzeigenamen und Zeitzonen-Einstellungen."
|
||||
},
|
||||
"circle": {
|
||||
"title": "Kreis-Einstellungen",
|
||||
"description": "Verwalte deinen Kreis, lade Mitglieder ein und bearbeite Beitrittsanfragen."
|
||||
},
|
||||
"account": {
|
||||
"title": "Account-Einstellungen",
|
||||
"description": "Verwalte dein Abonnement, ändere dein Passwort und Account-Löschoptionen."
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "Verwaltete Accounts",
|
||||
"description": "Erstelle und verwalte Unter-Accounts zum Anmelden und Erledigen zugewiesener Aufgaben."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"description": "Konfiguriere Push-Benachrichtigungen, E-Mail-Benachrichtigungen und Benachrichtigungsziele für Aufgaben."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Multi-Faktor-Authentifizierung",
|
||||
"description": "Füge eine zusätzliche Sicherheitsebene mit MFA über Authenticator-Apps hinzu."
|
||||
},
|
||||
"apitokens": {
|
||||
"title": "API-Token",
|
||||
"description": "Generiere und verwalte Zugriffstoken für Drittanbieter-Integrationen und API-Zugang."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Speicher-Einstellungen",
|
||||
"description": "Sichere und stelle deine Daten wieder her, verwalte lokalen Speicher und Synchronisierungseinstellungen."
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Seitenleisten-Anpassung",
|
||||
"description": "Passe das Layout und die Sichtbarkeit von Karten in der Seitenleisten-Oberfläche an."
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme-Einstellungen",
|
||||
"description": "Wähle dein bevorzugtes Theme und konfiguriere Dunkel-/Hell-Modus-Einstellungen."
|
||||
},
|
||||
"localization": {
|
||||
"title": "Sprach- und Formateinstellungen",
|
||||
"description": "Passe Sprache, Datumsformat, Zeitformat und regionale Einstellungen an."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Erweiterte Einstellungen",
|
||||
"description": "Konfiguriere Webhooks, Echtzeit-Updates und andere erweiterte Funktionen für erhöhte Produktivität."
|
||||
},
|
||||
"developer": {
|
||||
"title": "Entwickler-Einstellungen",
|
||||
"description": "Zeige technische Informationen über Authentifizierungs-Token, SSE-Verbindungen und Debug-Daten an."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
public/locales/pt/chores.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"title": "Tarefas",
|
||||
"myChores": "Minhas tarefas",
|
||||
"allChores": "Todas as tarefas",
|
||||
"addChore": "Adicionar tarefa",
|
||||
"editChore": "Editar tarefa",
|
||||
"deleteChore": "Excluir tarefa",
|
||||
"completeChore": "Concluir tarefa",
|
||||
"dueDate": "Data de vencimento",
|
||||
"assignedTo": "Atribuída a",
|
||||
"priority": "Prioridade",
|
||||
"status": "Status",
|
||||
"description": "Descrição",
|
||||
"choreView": {
|
||||
"assignment": "Atribuição",
|
||||
"assigned": "Atribuída",
|
||||
"last": "Última",
|
||||
"schedule": "Agendamento",
|
||||
"due": "Vence em",
|
||||
"statistics": "Estatísticas",
|
||||
"completed": "Concluída",
|
||||
"times": "vezes",
|
||||
"details": "Detalhes",
|
||||
"createdBy": "Criada por",
|
||||
"na": "N/D",
|
||||
"taskCompleted": "Tarefa concluída",
|
||||
"taskCompletedMessage": "Sua tarefa foi marcada como concluída",
|
||||
"taskCompletionUndone": "A conclusão da tarefa foi desfeita.",
|
||||
"taskSkipUndone": "O pulo da tarefa foi desfeito.",
|
||||
"undoSuccessful": "Desfeito com sucesso",
|
||||
"undoFailed": "Falha ao desfazer",
|
||||
"undoFailedMessage": "Não foi possível desfazer a ação. Tente novamente.",
|
||||
"resetTimer": "Reiniciar cronômetro",
|
||||
"resetTimerConfirmation": "Tem certeza de que deseja reiniciar o cronômetro? Isso apagará todos os registros de tempo desde o início da tarefa.",
|
||||
"clearAllTimeRecords": "Apagar todos os registros de tempo",
|
||||
"clearAllTimeConfirmation": "Isso apagará permanentemente todos os cronômetros desta tarefa e a marcará como \"não iniciada\".",
|
||||
"descriptionTitle": "Descrição",
|
||||
"description": "Descrição :",
|
||||
"previousNote": "Nota anterior",
|
||||
"previousNoteLabel": "Nota anterior:",
|
||||
"subtasksLabel": "Subtarefas :",
|
||||
"taskActions": "Ações da tarefa",
|
||||
"addNote": "Adicionar uma nota",
|
||||
"additionalNotes": "Notas adicionais:",
|
||||
"notePlaceholder": "Adicione uma nota sobre a conclusão...",
|
||||
"setCustomCompletionTime": "Definir horário de conclusão personalizado",
|
||||
"skipTask": "Pular tarefa",
|
||||
"skipTaskConfirmation": "Tem certeza de que deseja pular esta tarefa?",
|
||||
"markComplete": "Marcar como concluída",
|
||||
"markAsDone": "Marcar como concluída",
|
||||
"edit": "Editar",
|
||||
"archive": "Arquivar",
|
||||
"unarchive": "Desarquivar",
|
||||
"viewHistory": "Ver histórico",
|
||||
"history": "Histórico",
|
||||
"startTimer": "Iniciar cronômetro",
|
||||
"start": "Iniciar",
|
||||
"pauseTimer": "Pausar cronômetro",
|
||||
"approve": "Aprovar",
|
||||
"reject": "Rejeitar",
|
||||
"pendingApproval": "Aguardando aprovação",
|
||||
"undo": "Desfazer",
|
||||
"skip": "Pular",
|
||||
"cancel": "Cancelar",
|
||||
"noPriority": "Sem prioridade",
|
||||
"subtasks": "Subtarefas",
|
||||
"noDescription": "Nenhuma descrição disponível",
|
||||
"timer": {
|
||||
"active": "Cronômetro ativo",
|
||||
"paused": "Cronômetro pausado",
|
||||
"reset": "Reiniciar cronômetro",
|
||||
"delete": "Excluir sessão"
|
||||
}
|
||||
}
|
||||
}
|
||||
33
public/locales/pt/common.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"save": "Salvar",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Excluir",
|
||||
"edit": "Editar",
|
||||
"close": "Fechar",
|
||||
"confirm": "Confirmar",
|
||||
"loading": "Carregando...",
|
||||
"error": "Erro",
|
||||
"success": "Sucesso",
|
||||
"warning": "Aviso",
|
||||
"refresh": "Atualizar",
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado!",
|
||||
"settings": "Configurações",
|
||||
"yes": "Sim",
|
||||
"no": "Não",
|
||||
"back": "Voltar",
|
||||
"backToCalendar": "Voltar ao calendário",
|
||||
"logout": "Sair",
|
||||
"version": "Versão",
|
||||
"navigation": {
|
||||
"allTasks": "Todas as tarefas",
|
||||
"archived": "Arquivadas",
|
||||
"things": "Itens",
|
||||
"labels": "Etiquetas",
|
||||
"projects": "Projetos",
|
||||
"filters": "Filtros",
|
||||
"activities": "Atividades",
|
||||
"points": "Pontos",
|
||||
"settings": "Configurações"
|
||||
}
|
||||
}
|
||||
174
public/locales/pt/settings.json
Normal file
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"title": "Configurações",
|
||||
"circleSettings": {
|
||||
"title": "Configurações do círculo",
|
||||
"description": "Sua conta é conectada automaticamente a um círculo quando você cria ou entra em um. Convide amigos facilmente compartilhando o código ou link exclusivo do círculo abaixo. Você receberá uma notificação abaixo quando alguém solicitar entrar no seu círculo. Se quiser sair, basta clicar no botão \"Sair do círculo\".",
|
||||
"circleCode": "Código do círculo",
|
||||
"copyCode": "Copiar código",
|
||||
"copyLink": "Copiar link",
|
||||
"codeCopied": "Código do círculo copiado!",
|
||||
"linkCopied": "Link do círculo copiado!",
|
||||
"joinCircle": "Entrar em um círculo",
|
||||
"joinCirclePlaceholder": "Digite o código do círculo",
|
||||
"join": "Entrar",
|
||||
"leave": "Sair do círculo",
|
||||
"leaveConfirmTitle": "Sair do círculo",
|
||||
"leaveConfirmMessage": "Tem certeza de que deseja sair deste círculo?",
|
||||
"circleMembers": "Membros do círculo",
|
||||
"circleMemberRequests": "Solicitações de entrada no círculo",
|
||||
"admin": "Administrador",
|
||||
"member": "Membro",
|
||||
"pending": "Pendente",
|
||||
"accept": "Aceitar",
|
||||
"reject": "Rejeitar",
|
||||
"makeAdmin": "Tornar administrador",
|
||||
"makeMember": "Tornar membro",
|
||||
"remove": "Remover",
|
||||
"webhookURL": "URL do webhook",
|
||||
"webhookDescription": "Digite uma URL de webhook para receber notificações de eventos do círculo",
|
||||
"webhookPlaceholder": "https://sua-url-de-webhook.com"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "Configurações da conta",
|
||||
"subscription": "Assinatura",
|
||||
"subscriptionStatus": "Plano atual",
|
||||
"free": "Gratuito",
|
||||
"plus": "Plus",
|
||||
"upgrade": "Fazer upgrade",
|
||||
"cancel": "Cancelar",
|
||||
"changePassword": "Alterar senha",
|
||||
"password": "Senha",
|
||||
"dangerZone": "Zona de perigo",
|
||||
"dangerZoneDescription": "Após excluir sua conta, não há como voltar atrás. Tenha certeza antes de confirmar.",
|
||||
"deleteAccount": "Excluir conta"
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localização",
|
||||
"description": "Personalize idioma, formato de data e preferências regionais da sua conta.",
|
||||
"language": "Idioma",
|
||||
"languageDescription": "Selecione seu idioma preferido",
|
||||
"dateFormat": "Formato de data",
|
||||
"dateFormatDescription": "Escolha como as datas devem ser exibidas no aplicativo",
|
||||
"timeFormat": "Formato de hora",
|
||||
"timeFormatDescription": "Selecione o formato de 12 ou 24 horas",
|
||||
"12hour": "12 horas (AM/PM)",
|
||||
"24hour": "24 horas",
|
||||
"firstDayOfWeek": "Primeiro dia da semana",
|
||||
"firstDayOfWeekDescription": "Selecione com qual dia sua semana começa",
|
||||
"sunday": "Domingo",
|
||||
"monday": "Segunda-feira",
|
||||
"saturday": "Sábado",
|
||||
"formats": {
|
||||
"mdy": "MM/DD/AAAA (EUA)",
|
||||
"dmy": "DD/MM/AAAA (Europa)",
|
||||
"ymd": "AAAA-MM-DD (ISO)",
|
||||
"long": "Formato longo (ex.: 1 de janeiro de 2024)",
|
||||
"short": "Formato curto (ex.: 1 de jan de 2024)"
|
||||
}
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Personalização do painel lateral",
|
||||
"description": "Personalize o layout e a visibilidade dos cartões no painel lateral. Esta seção está disponível apenas em dispositivos com telas grandes, como tablets e computadores."
|
||||
},
|
||||
"theme": {
|
||||
"title": "Preferências de tema",
|
||||
"description": "Escolha a aparência do site. Selecione um único tema ou sincronize com o sistema para alternar automaticamente entre os temas claro e escuro.",
|
||||
"themeMode": "Modo do tema",
|
||||
"light": "Claro",
|
||||
"dark": "Escuro",
|
||||
"system": "Sistema"
|
||||
},
|
||||
"notifications": {
|
||||
"settingsSaved": "Configurações salvas com sucesso",
|
||||
"settingsSaveFailed": "Falha ao salvar as configurações",
|
||||
"invalidWebhook": "URL de webhook inválida"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Configurações do perfil",
|
||||
"description": "Atualize seu nome de exibição e foto de perfil.",
|
||||
"photoUpdated": "Foto atualizada",
|
||||
"photoUpdatedMessage": "Sua foto de perfil foi atualizada com sucesso!",
|
||||
"uploadFailed": "Falha no envio",
|
||||
"uploadFailedMessage": "Falha ao enviar sua foto. Tente novamente.",
|
||||
"profileUpdated": "Perfil atualizado",
|
||||
"profileUpdatedMessage": "As informações do seu perfil foram salvas com sucesso!",
|
||||
"updateFailed": "Falha na atualização",
|
||||
"updateFailedMessage": "Não foi possível atualizar seu perfil. Verifique sua conexão e tente novamente.",
|
||||
"changePhoto": "Alterar foto",
|
||||
"displayName": "Nome de exibição",
|
||||
"displayNamePlaceholder": "Digite seu nome de exibição",
|
||||
"timezone": "Fuso horário",
|
||||
"timezonePlaceholder": "Selecione seu fuso horário",
|
||||
"save": "Salvar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Configurações",
|
||||
"subtitle": "Personalize sua experiência e gerencie as preferências da sua conta",
|
||||
"upgrade": {
|
||||
"title": "Faça upgrade para o Plus",
|
||||
"description": "Desbloqueie recursos poderosos para aumentar sua produtividade",
|
||||
"button": "Fazer upgrade agora",
|
||||
"features": {
|
||||
"richText": "Descrições com texto formatado",
|
||||
"notifications": "Notificações de tarefas",
|
||||
"apiIntegrations": "Integrações de API",
|
||||
"advancedAutomation": "Automação avançada"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"title": "Configurações do perfil",
|
||||
"description": "Atualize as informações do seu perfil, foto, nome de exibição e preferências de fuso horário."
|
||||
},
|
||||
"circle": {
|
||||
"title": "Configurações do círculo",
|
||||
"description": "Gerencie seu círculo, convide membros e cuide das solicitações de entrada."
|
||||
},
|
||||
"account": {
|
||||
"title": "Configurações da conta",
|
||||
"description": "Gerencie sua assinatura, altere a senha e as opções de exclusão de conta."
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "Contas gerenciadas",
|
||||
"description": "Crie e gerencie subcontas para fazer login e concluir tarefas atribuídas."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificações",
|
||||
"description": "Configure notificações push, alertas por e-mail e destinos de notificação para tarefas."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Autenticação multifator",
|
||||
"description": "Adicione uma camada extra de segurança com MFA usando aplicativos autenticadores."
|
||||
},
|
||||
"apitokens": {
|
||||
"title": "Tokens de API",
|
||||
"description": "Gere e gerencie tokens de acesso para integrações de terceiros e acesso à API."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Configurações de armazenamento",
|
||||
"description": "Faça backup e restauração dos seus dados, gerencie o armazenamento local e as preferências de sincronização."
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Personalização do painel lateral",
|
||||
"description": "Personalize o layout e a visibilidade dos cartões na interface do painel lateral."
|
||||
},
|
||||
"theme": {
|
||||
"title": "Preferências de tema",
|
||||
"description": "Escolha seu tema preferido e configure o modo claro/escuro."
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localização",
|
||||
"description": "Personalize idioma, formato de data, formato de hora e preferências regionais."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Configurações avançadas",
|
||||
"description": "Configure webhooks, atualizações em tempo real e outros recursos avançados para mais produtividade."
|
||||
},
|
||||
"developer": {
|
||||
"title": "Configurações de desenvolvedor",
|
||||
"description": "Veja informações técnicas sobre tokens de autenticação, conexões SSE e dados de depuração."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/App.jsx
@@ -1,14 +1,18 @@
|
||||
import NavBar from '@/views/components/NavBar'
|
||||
import { Button, Typography, useColorScheme } from '@mui/joy'
|
||||
import Tracker from '@openreplay/tracker'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
import { registerCapacitorListeners } from './CapacitorListener'
|
||||
import PageTransition from './components/animations/PageTransition'
|
||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||
import SSEProvider from './contexts/SSEContext'
|
||||
import { AuthProvider } from './hooks/useAuth.jsx'
|
||||
|
||||
import useStatusBar from './hooks/useStatusBar'
|
||||
import { useResource } from './queries/ResourceQueries'
|
||||
import './styles/safe-area.css'
|
||||
|
||||
import SSEProvider from './contexts/SSEContext'
|
||||
import { useNotification } from './service/NotificationProvider'
|
||||
|
||||
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
|
||||
@@ -25,19 +29,16 @@ const remove = className => {
|
||||
// TODO: Update the interval to at 60 minutes
|
||||
const intervalMS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
const startOpenReplay = () => {
|
||||
if (!import.meta.env.VITE_OPENREPLAY_PROJECT_KEY) return
|
||||
const tracker = new Tracker({
|
||||
projectKey: import.meta.env.VITE_OPENREPLAY_PROJECT_KEY,
|
||||
})
|
||||
tracker.start()
|
||||
}
|
||||
|
||||
const AppContent = () => {
|
||||
const { showNotification } = useNotification()
|
||||
useSyncOnReconnect()
|
||||
|
||||
// Initialize status bar with theme-aware configuration
|
||||
useStatusBar()
|
||||
|
||||
|
||||
const {
|
||||
offlineReady: [offlineReady, setOfflineReady], // eslint-disable-line no-unused-vars
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW({
|
||||
@@ -96,10 +97,11 @@ const AppContent = () => {
|
||||
}
|
||||
|
||||
function App() {
|
||||
// startOpenReplay()
|
||||
|
||||
const resource = useResource() // eslint-disable-line no-unused-vars
|
||||
const { mode, systemMode } = useColorScheme()
|
||||
|
||||
// startOpenReplay()
|
||||
|
||||
const setThemeClass = useCallback(() => {
|
||||
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
|
||||
|
||||
@@ -126,7 +128,7 @@ function App() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<NetworkBanner />
|
||||
|
||||
<AuthProvider>
|
||||
@@ -134,7 +136,7 @@ function App() {
|
||||
<AppContent />
|
||||
</SSEProvider>
|
||||
</AuthProvider>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,35 @@ import { PushNotifications } from '@capacitor/push-notifications'
|
||||
import { focusManager } from '@tanstack/react-query'
|
||||
import { RegisterDeviceToken } from './utils/Fetcher'
|
||||
|
||||
// NFC chore deep link: donetick://chores/123?auto_complete=true
|
||||
const handleNFCChoreDeepLink = url => {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
// donetick://chores/123 → host='chores', pathname='/123'
|
||||
const choreId = urlObj.pathname.slice(1)
|
||||
const autoComplete = urlObj.searchParams.get('auto_complete')
|
||||
const path = `/chores/${choreId}${autoComplete ? '?auto_complete=' + autoComplete : ''}`
|
||||
|
||||
// getLaunchUrl() persists across every WebView reload caused by window.location.href.
|
||||
// If we're already on the target page, skip to avoid an infinite reload loop.
|
||||
if (window.location.pathname + window.location.search === path) return
|
||||
|
||||
console.log('[NFC] navigating to', path)
|
||||
window.location.href = path
|
||||
} catch (error) {
|
||||
console.error('[NFC] Error handling chore deep link:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUrlOpen = url => {
|
||||
console.log('[NFC] handleUrlOpen:', url)
|
||||
if (url.startsWith('donetick://chores/')) {
|
||||
handleNFCChoreDeepLink(url)
|
||||
} else if (url.startsWith('donetick://auth/')) {
|
||||
handleOAuthDeepLink(url)
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth callback handler for deep links
|
||||
const handleOAuthDeepLink = async url => {
|
||||
console.log('OAuth deep link received:', url)
|
||||
@@ -215,16 +244,20 @@ const registerCapacitorListeners = () => {
|
||||
return
|
||||
}
|
||||
localNotificationListenerRegistration()
|
||||
|
||||
// Register deep link handler for OAuth and other deep links
|
||||
mobileApp.addListener('appUrlOpen', event => {
|
||||
console.log('App URL opened:', event.url)
|
||||
|
||||
// Handle OAuth callback
|
||||
if (event.url.startsWith('donetick://auth/')) {
|
||||
handleOAuthDeepLink(event.url)
|
||||
|
||||
// Cold-start: app was launched by tapping an NFC tag (or other deep link)
|
||||
mobileApp.getLaunchUrl().then(result => {
|
||||
if (result?.url) {
|
||||
console.log('[NFC] getLaunchUrl:', result.url)
|
||||
handleUrlOpen(result.url)
|
||||
}
|
||||
})
|
||||
|
||||
// Foreground / singleTask resume: app was already running when the tag was tapped
|
||||
mobileApp.addListener('appUrlOpen', event => {
|
||||
console.log('[NFC] appUrlOpen:', event.url)
|
||||
handleUrlOpen(event.url)
|
||||
})
|
||||
|
||||
mobileApp.addListener('appStateChange', ({ isActive }) => {
|
||||
focusManager.setFocused(isActive)
|
||||
@@ -233,6 +266,9 @@ const registerCapacitorListeners = () => {
|
||||
mobileApp.addListener('backButton', ({ canGoBack }) => {
|
||||
if (canGoBack) {
|
||||
window.history.back()
|
||||
} else if (window.location.pathname !== '/') {
|
||||
// No history (e.g. app launched directly to a chore via NFC) — go home
|
||||
window.location.href = '/'
|
||||
} else {
|
||||
mobileApp.exitApp()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import Input from '@mui/joy/Input'
|
||||
import Option from '@mui/joy/Option'
|
||||
import Select from '@mui/joy/Select'
|
||||
import Typography from '@mui/joy/Typography'
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
|
||||
import { TIME_UNITS } from '../utils/DurationUtils'
|
||||
|
||||
@@ -512,6 +512,7 @@ const NotificationTemplate = ({
|
||||
'--Badge-fontSize': '0.7rem',
|
||||
'--Badge-paddingX': '5px',
|
||||
top: 10,
|
||||
left: 10,
|
||||
'& .MuiBadge-badge': {
|
||||
background: colors.bgColor,
|
||||
color: 'white',
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useImpersonateUser } from '../contexts/ImpersonateUserContext'
|
||||
import useStickyState from '../hooks/useStickyState'
|
||||
import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { isPlusAccount } from '../utils/Helpers'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||
import UserModal from '../views/Modals/Inputs/UserModal'
|
||||
import SubscriptionModal from './SubscriptionModal'
|
||||
|
||||
@@ -116,7 +116,7 @@ const UserProfileAvatar = () => {
|
||||
{isImpersonating ? (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Avatar
|
||||
src={currentUser?.image || currentUser?.avatar}
|
||||
src={resolvePhotoURL(currentUser?.image)}
|
||||
alt={currentUser?.displayName || currentUser?.name}
|
||||
size='md'
|
||||
sx={{
|
||||
@@ -127,7 +127,7 @@ const UserProfileAvatar = () => {
|
||||
}}
|
||||
/>
|
||||
<Avatar
|
||||
src={userProfile?.image || userProfile?.avatar}
|
||||
src={resolvePhotoURL(userProfile?.image || userProfile?.avatar)}
|
||||
alt={userProfile?.displayName || userProfile?.name}
|
||||
size='sm'
|
||||
sx={{
|
||||
@@ -162,7 +162,7 @@ const UserProfileAvatar = () => {
|
||||
</Box>
|
||||
) : (
|
||||
<Avatar
|
||||
src={currentUser?.image || currentUser?.avatar}
|
||||
src={resolvePhotoURL(currentUser?.image || currentUser?.avatar)}
|
||||
alt={currentUser?.displayName || currentUser?.name}
|
||||
size='md'
|
||||
sx={{
|
||||
@@ -189,7 +189,7 @@ const UserProfileAvatar = () => {
|
||||
<Sheet sx={{ p: 2, borderRadius: 'var(--joy-radius-sm)', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Avatar
|
||||
src={currentUser?.image || currentUser?.avatar}
|
||||
src={resolvePhotoURL(currentUser?.image || currentUser?.avatar)}
|
||||
alt={currentUser?.displayName || currentUser?.name}
|
||||
size='lg'
|
||||
sx={{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
|
||||
import { Modal, ModalClose, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
/**
|
||||
@@ -78,6 +78,7 @@ const FadeModal = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalClose />
|
||||
{title && (
|
||||
<Typography level='title-lg' sx={{ fontWeight: 600, mb: 2 }}>
|
||||
{title}
|
||||
|
||||
506
src/components/common/FilterBar.jsx
Normal file
@@ -0,0 +1,506 @@
|
||||
import { Check, FilterList, Tune } from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
Input,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import BottomSheetModal from './BottomSheetModal'
|
||||
import ActiveFilterChips from './filter/ActiveFilterChips'
|
||||
|
||||
/**
|
||||
* Reusable filter bar component.
|
||||
*
|
||||
* Props:
|
||||
* filterDefs - array of filter definitions:
|
||||
* { id, label, type ('multi-select'|'single-select'|'boolean'|'date-range'),
|
||||
* icon, options?, defaultValue?, filterFn }
|
||||
* options item: { value, label, color?, icon?, avatar? }
|
||||
* defaultValue: if the active value equals this, no chip is shown
|
||||
* date-range value shape: { preset?, from?: ISO string, to?: ISO string }
|
||||
* activeFilters - current filter state object { [id]: value }
|
||||
* onSetFilter - (filterId, value | null) => void
|
||||
* onClearAll - () => void
|
||||
* resultCount - optional number shown in "Show N results" button
|
||||
* totalCount - optional total for "N of M" label
|
||||
*/
|
||||
|
||||
// ── Date range presets (no moment dependency — pure Date) ────────────────────
|
||||
|
||||
const d = (date, h = 0, m = 0, s = 0, ms = 0) =>
|
||||
new Date(date.getFullYear(), date.getMonth(), date.getDate(), h, m, s, ms)
|
||||
|
||||
const DATE_RANGE_PRESETS = [
|
||||
{
|
||||
value: 'today',
|
||||
label: 'Today',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
return { from: t.toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'yesterday',
|
||||
label: 'Yesterday',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const y = new Date(t); y.setDate(t.getDate() - 1)
|
||||
return { from: d(y).toISOString(), to: d(y, 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'this-week',
|
||||
label: 'This Week',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - t.getDay())
|
||||
const end = new Date(start); end.setDate(start.getDate() + 6)
|
||||
return { from: d(start).toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'last-7-days',
|
||||
label: 'Last 7 Days',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - 6)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'this-month',
|
||||
label: 'This Month',
|
||||
getRange: () => {
|
||||
const n = new Date()
|
||||
const start = new Date(n.getFullYear(), n.getMonth(), 1)
|
||||
const end = new Date(n.getFullYear(), n.getMonth() + 1, 0)
|
||||
return { from: start.toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'last-30-days',
|
||||
label: 'Last 30 Days',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - 29)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'last-3-months',
|
||||
label: 'Last 3 Months',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setMonth(t.getMonth() - 3)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const toInputDate = iso => (iso ? iso.split('T')[0] : '')
|
||||
|
||||
const fmtDisplayDate = iso => {
|
||||
if (!iso) return null
|
||||
const dt = new Date(iso)
|
||||
return dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
const FilterBar = ({
|
||||
filterDefs,
|
||||
activeFilters,
|
||||
onSetFilter,
|
||||
onClearAll,
|
||||
resultCount,
|
||||
totalCount,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
// ── Active count ───────────────────────────────────────────────────────────
|
||||
|
||||
const activeFilterCount = filterDefs.filter(def => {
|
||||
const value = activeFilters[def.id]
|
||||
if (value === undefined || value === null) return false
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue) return false
|
||||
if (Array.isArray(value) && value.length === 0) return false
|
||||
if (def.type === 'date-range') return !!(value?.from || value?.to)
|
||||
return true
|
||||
}).length
|
||||
|
||||
const hasActive = activeFilterCount > 0
|
||||
|
||||
const selectableChipSx = {
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
alignItems: 'center',
|
||||
'& .MuiChip-startDecorator': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 0.5,
|
||||
},
|
||||
'& .MuiChip-label': {
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
'&:hover': { opacity: 0.85 },
|
||||
}
|
||||
|
||||
const sectionBadgeChipSx = {
|
||||
ml: 'auto',
|
||||
fontSize: '0.7rem',
|
||||
minHeight: 22,
|
||||
py: 0.25,
|
||||
px: 0.75,
|
||||
alignItems: 'center',
|
||||
'& .MuiChip-label': {
|
||||
lineHeight: 1.2,
|
||||
px: 0,
|
||||
},
|
||||
}
|
||||
|
||||
const modalCountChipSx = {
|
||||
ml: 0.5,
|
||||
minHeight: 22,
|
||||
py: 0.25,
|
||||
px: 0.75,
|
||||
alignItems: 'center',
|
||||
'& .MuiChip-label': {
|
||||
lineHeight: 1.2,
|
||||
px: 0,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Chip labels for inline bar ─────────────────────────────────────────────
|
||||
|
||||
const getActiveChipLabel = def => {
|
||||
const value = activeFilters[def.id]
|
||||
if (value === undefined || value === null) return null
|
||||
|
||||
if (def.type === 'single-select') {
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue) return null
|
||||
return def.options?.find(o => o.value === value)?.label ?? def.label
|
||||
}
|
||||
|
||||
if (def.type === 'boolean') return def.label
|
||||
|
||||
if (def.type === 'multi-select' && Array.isArray(value) && value.length > 0) {
|
||||
if (value.length === 1) {
|
||||
return def.options?.find(o => o.value === value[0])?.label ?? def.label
|
||||
}
|
||||
return `${def.label} (${value.length})`
|
||||
}
|
||||
|
||||
if (def.type === 'date-range') {
|
||||
if (!value?.from && !value?.to) return null
|
||||
if (value.preset) {
|
||||
return DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ?? 'Date Range'
|
||||
}
|
||||
const from = fmtDisplayDate(value.from)
|
||||
const to = fmtDisplayDate(value.to)
|
||||
if (from && to) return `${from} – ${to}`
|
||||
if (from) return `From ${from}`
|
||||
if (to) return `Until ${to}`
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const handleMultiToggle = (defId, optValue) => {
|
||||
const current = activeFilters[defId] || []
|
||||
const next = current.includes(optValue)
|
||||
? current.filter(v => v !== optValue)
|
||||
: [...current, optValue]
|
||||
onSetFilter(defId, next.length > 0 ? next : null)
|
||||
}
|
||||
|
||||
const handleSingleToggle = (defId, optValue) => {
|
||||
onSetFilter(defId, activeFilters[defId] === optValue ? null : optValue)
|
||||
}
|
||||
|
||||
const handleBoolToggle = defId => {
|
||||
onSetFilter(defId, activeFilters[defId] ? null : true)
|
||||
}
|
||||
|
||||
const handleDateRangePreset = (defId, presetValue) => {
|
||||
const current = activeFilters[defId] || {}
|
||||
if (current.preset === presetValue) {
|
||||
onSetFilter(defId, null)
|
||||
return
|
||||
}
|
||||
const preset = DATE_RANGE_PRESETS.find(p => p.value === presetValue)
|
||||
onSetFilter(defId, { preset: presetValue, ...preset.getRange() })
|
||||
}
|
||||
|
||||
const handleDateRangeInput = (defId, field, dateStr) => {
|
||||
const current = activeFilters[defId] || {}
|
||||
if (!dateStr) {
|
||||
const next = { ...current, preset: null, [field]: null }
|
||||
onSetFilter(defId, next.from || next.to ? next : null)
|
||||
} else {
|
||||
const iso =
|
||||
field === 'to'
|
||||
? new Date(dateStr + 'T23:59:59').toISOString()
|
||||
: new Date(dateStr + 'T00:00:00').toISOString()
|
||||
onSetFilter(defId, { ...current, preset: null, [field]: iso })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Inline bar ─────────────────────────────────────── */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Badge
|
||||
badgeContent={activeFilterCount || null}
|
||||
color='primary'
|
||||
size='sm'
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Button
|
||||
size='md'
|
||||
variant={hasActive ? 'solid' : 'outlined'}
|
||||
color={hasActive ? 'primary' : 'neutral'}
|
||||
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
borderRadius: 'xl',
|
||||
py: 0.5,
|
||||
px: 1,
|
||||
gap: 0.5,
|
||||
alignItems: 'center',
|
||||
'& .MuiButton-startDecorator': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 0.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</Badge>
|
||||
|
||||
<ActiveFilterChips
|
||||
chips={filterDefs
|
||||
.map(def => ({ def, label: getActiveChipLabel(def) }))
|
||||
.filter(({ label }) => !!label)
|
||||
.map(({ def, label }) => ({
|
||||
key: def.id,
|
||||
label,
|
||||
onClear: () => onSetFilter(def.id, null),
|
||||
}))}
|
||||
onOpen={() => setIsOpen(true)}
|
||||
onClearAll={hasActive ? onClearAll : undefined}
|
||||
resultCount={hasActive ? resultCount : undefined}
|
||||
totalCount={hasActive ? totalCount : undefined}
|
||||
maxVisible={2}
|
||||
chipSize='md'
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* ── Bottom sheet ────────────────────────────────────── */}
|
||||
<BottomSheetModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tune sx={{ fontSize: 20 }} />
|
||||
Filters
|
||||
{hasActive && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={modalCountChipSx}>
|
||||
{activeFilterCount}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
disabled={!hasActive}
|
||||
onClick={onClearAll}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
<Button onClick={() => setIsOpen(false)} sx={{ minWidth: 140 }}>
|
||||
{resultCount !== undefined
|
||||
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
|
||||
: 'Done'}
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{filterDefs.map((def, idx) => (
|
||||
<Box key={def.id}>
|
||||
{idx > 0 && <Divider sx={{ my: 2.5 }} />}
|
||||
|
||||
{/* Section header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
{def.icon && (
|
||||
<Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', '& svg': { fontSize: 18 } }}>
|
||||
{def.icon}
|
||||
</Box>
|
||||
)}
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{def.label}
|
||||
</Typography>
|
||||
|
||||
{/* active badge in header */}
|
||||
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
{activeFilters[def.id].length} selected
|
||||
</Chip>
|
||||
)}
|
||||
{def.type === 'single-select' && activeFilters[def.id] != null && (() => {
|
||||
const opt = def.options?.find(o => o.value === activeFilters[def.id])
|
||||
return opt ? (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
) : null
|
||||
})()}
|
||||
{def.type === 'date-range' && getActiveChipLabel(def) && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
{getActiveChipLabel(def)}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* multi-select */}
|
||||
{def.type === 'multi-select' && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{def.options?.map(opt => {
|
||||
const isSelected = (activeFilters[def.id] || []).includes(opt.value)
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
startDecorator={
|
||||
opt.avatar ? (
|
||||
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
|
||||
) : isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (opt.icon ?? null)
|
||||
}
|
||||
onClick={() => handleMultiToggle(def.id, opt.value)}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* single-select */}
|
||||
{def.type === 'single-select' && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{def.options?.map(opt => {
|
||||
const isSelected = activeFilters[def.id] === opt.value
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
startDecorator={
|
||||
opt.avatar ? (
|
||||
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
|
||||
) : isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (opt.icon ?? null)
|
||||
}
|
||||
onClick={() => handleSingleToggle(def.id, opt.value)}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* boolean */}
|
||||
{def.type === 'boolean' && (
|
||||
<Chip
|
||||
variant={activeFilters[def.id] ? 'solid' : 'soft'}
|
||||
color={activeFilters[def.id] ? 'primary' : 'neutral'}
|
||||
startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||
onClick={() => handleBoolToggle(def.id)}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
{def.label}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{/* date-range */}
|
||||
{def.type === 'date-range' && (() => {
|
||||
const val = activeFilters[def.id] || {}
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{/* Preset chips */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{DATE_RANGE_PRESETS.map(preset => {
|
||||
const isSelected = val.preset === preset.value
|
||||
return (
|
||||
<Chip
|
||||
key={preset.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? 'primary' : 'neutral'}
|
||||
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||
onClick={() => handleDateRangePreset(def.id, preset.value)}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
{preset.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Custom date inputs */}
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.from)}
|
||||
onChange={e => handleDateRangeInput(def.id, 'from', e.target.value)}
|
||||
slotProps={{ input: { max: toInputDate(val.to) || undefined } }}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', flexShrink: 0 }}>
|
||||
–
|
||||
</Typography>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.to)}
|
||||
onChange={e => handleDateRangeInput(def.id, 'to', e.target.value)}
|
||||
slotProps={{ input: { min: toInputDate(val.from) || undefined } }}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterBar
|
||||
131
src/components/common/filter/ActiveFilterChips.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { Box, Button, Chip, Typography } from '@mui/joy'
|
||||
|
||||
const ActiveFilterChips = ({
|
||||
chips = [],
|
||||
onOpen,
|
||||
onClearAll,
|
||||
resultCount,
|
||||
totalCount,
|
||||
maxVisible = 2,
|
||||
chipSize = 'md',
|
||||
clearButtonSize = 'sm',
|
||||
clearButtonSx,
|
||||
containerSx,
|
||||
chipSx,
|
||||
overflowChipSx,
|
||||
resultSx,
|
||||
}) => {
|
||||
if (!chips.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const visible = chips.slice(0, maxVisible)
|
||||
const overflow = chips.length - maxVisible
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'nowrap',
|
||||
overflowX: 'auto',
|
||||
py: 0.5,
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
scrollbarWidth: 'none',
|
||||
...containerSx,
|
||||
}}
|
||||
>
|
||||
{visible.map(({ key, label, onClear, color = 'primary' }) => (
|
||||
<Chip
|
||||
key={key}
|
||||
size={chipSize}
|
||||
variant='soft'
|
||||
color={color}
|
||||
endDecorator={
|
||||
<Close
|
||||
sx={{ cursor: 'pointer', fontSize: chipSize === 'sm' ? 12 : 16 }}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={onOpen}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.15s ease',
|
||||
alignItems: 'center',
|
||||
'& .MuiChip-endDecorator': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
ml: 0.5,
|
||||
},
|
||||
'& .MuiChip-label': {
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
'&:hover': { opacity: 0.85 },
|
||||
...chipSx,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
{overflow > 0 && (
|
||||
<Chip
|
||||
size={chipSize}
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
onClick={onOpen}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.15s ease',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
...overflowChipSx,
|
||||
}}
|
||||
>
|
||||
+{overflow} more
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{resultCount != null && totalCount != null && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
ml: 'auto',
|
||||
flexShrink: 0,
|
||||
...resultSx,
|
||||
}}
|
||||
>
|
||||
{resultCount} / {totalCount}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{onClearAll && (
|
||||
<Button
|
||||
size={clearButtonSize}
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={onClearAll}
|
||||
sx={{
|
||||
px: 0.5,
|
||||
fontSize: chipSize === 'sm' ? '0.72rem' : '0.75rem',
|
||||
color: 'text.secondary',
|
||||
minHeight: 0,
|
||||
flexShrink: 0,
|
||||
...clearButtonSx,
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActiveFilterChips
|
||||
@@ -21,11 +21,13 @@ export const TIME_FORMATS = {
|
||||
export const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur']
|
||||
|
||||
export const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'de', name: 'German', nativeName: 'Deutsch' },
|
||||
{ code: 'en', name: 'English', nativeName: 'English' },
|
||||
{ code: 'es', name: 'Spanish', nativeName: 'Español' },
|
||||
{ code: 'fr', name: 'French', nativeName: 'Français' },
|
||||
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||
{ code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
|
||||
]
|
||||
|
||||
export const LocalizationProvider = ({ children }) => {
|
||||
|
||||
@@ -1,47 +1,83 @@
|
||||
import { Network } from '@capacitor/network'
|
||||
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
|
||||
class NetworkManager {
|
||||
constructor() {
|
||||
this.isOnline = true
|
||||
this.isNetworkOn = null
|
||||
this.init()
|
||||
this.deviceOnline = true
|
||||
this.serverReachable = true
|
||||
this.offlineReason = null // 'device' | 'server' | null
|
||||
this.connectionStatusListeners = []
|
||||
this.queueSyncListeners = []
|
||||
this.lastChecked = null
|
||||
this.offlineSince = null
|
||||
this.init()
|
||||
}
|
||||
|
||||
// Effective online status: both device network AND server must be reachable
|
||||
get isOnline() {
|
||||
return this.deviceOnline && this.serverReachable
|
||||
}
|
||||
|
||||
// Alias for backward compatibility (DeveloperSettings uses this)
|
||||
get isNetworkOn() {
|
||||
return this.deviceOnline
|
||||
}
|
||||
|
||||
async init() {
|
||||
const status = await Network.getStatus()
|
||||
this.isNetworkOn = status.connected
|
||||
this.deviceOnline = status.connected
|
||||
this.lastChecked = Date.now()
|
||||
if (!status.connected) {
|
||||
this.offlineReason = 'device'
|
||||
this.offlineSince = Date.now()
|
||||
}
|
||||
|
||||
Network.addListener('networkStatusChange', status => {
|
||||
if (this.isNetworkOn !== status.connected) {
|
||||
this.isNetworkOn = status.connected
|
||||
if (this.deviceOnline !== status.connected) {
|
||||
this.deviceOnline = status.connected
|
||||
this.lastChecked = Date.now()
|
||||
this.isOnline = status.connected
|
||||
|
||||
if (!status.connected) {
|
||||
this.offlineReason = 'device'
|
||||
this.offlineSince = Date.now()
|
||||
} else {
|
||||
// Device came back online — update reason based on server state
|
||||
this.offlineReason = this.serverReachable ? null : 'server'
|
||||
}
|
||||
this.notifyConnectionStatus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setOffline() {
|
||||
if (this.isOnline === true) {
|
||||
this.isOnline = false
|
||||
// Called when a fetch() response is received (any HTTP status = server is up)
|
||||
setServerReachable() {
|
||||
if (!this.serverReachable) {
|
||||
this.serverReachable = true
|
||||
this.offlineReason = this.deviceOnline ? null : 'device'
|
||||
this.notifyConnectionStatus()
|
||||
this.offlineSince = Date.now() // Record the time when we went offline
|
||||
}
|
||||
}
|
||||
setOnline() {
|
||||
if (this.isOnline === false) {
|
||||
this.isOnline = true
|
||||
|
||||
// Called when fetch() throws a network error (server unreachable)
|
||||
// Only takes effect when offline mode is enabled
|
||||
setServerUnreachable() {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
if (this.serverReachable) {
|
||||
this.serverReachable = false
|
||||
this.offlineReason = 'server'
|
||||
this.offlineSince = Date.now()
|
||||
this.notifyConnectionStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy methods kept for compatibility
|
||||
setOffline() {
|
||||
this.setServerUnreachable()
|
||||
}
|
||||
setOnline() {
|
||||
this.setServerReachable()
|
||||
}
|
||||
|
||||
notifyConnectionStatus() {
|
||||
this.connectionStatusListeners.forEach(callback => {
|
||||
callback(this.isOnline)
|
||||
@@ -63,7 +99,6 @@ class NetworkManager {
|
||||
)
|
||||
}
|
||||
registerBackendSyncListener(callback) {
|
||||
// if callback is not in the list already, add it
|
||||
if (!this.queueSyncListeners.includes(callback)) {
|
||||
this.queueSyncListeners.push(callback)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export const AuthProvider = ({ children }) => {
|
||||
// Ensure apiClient is initialized with the correct URL
|
||||
await apiClient.init()
|
||||
const currentBaseURL = apiClient.getApiURL()
|
||||
|
||||
|
||||
const isNative =
|
||||
typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()
|
||||
|
||||
@@ -57,8 +57,8 @@ export const AuthProvider = ({ children }) => {
|
||||
const response = await fetch(`${currentBaseURL}/auth/login`, config)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
return { success: false, error: error.message || 'Login failed' }
|
||||
const res = await response.json()
|
||||
return { success: false, error: res?.error || 'Login failed' }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
50
src/hooks/useDocumentScanner.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
/**
|
||||
* Normalizes a raw image string from the native document scanner into a
|
||||
* format that can be used as an <img> src and passed to Tesseract.js.
|
||||
*
|
||||
* Android returns file:// or absolute paths → convert via Capacitor.convertFileSrc
|
||||
* iOS returns raw base64 (no data: prefix) → prepend the data URI scheme
|
||||
*/
|
||||
function normalizeScannedImage(raw) {
|
||||
if (!raw) return null
|
||||
if (raw.startsWith('data:')) return raw
|
||||
if (raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('content://')) return raw
|
||||
if (raw.startsWith('/') || raw.startsWith('file://')) return Capacitor.convertFileSrc(raw)
|
||||
// iOS base64 without prefix
|
||||
return `data:image/jpeg;base64,${raw}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for native document scanning via @capgo/capacitor-document-scanner.
|
||||
*
|
||||
* On native: opens the OS document scanner (edge detection, perspective correction).
|
||||
* On web: `scanDocument` returns null — callers should fall back to their own camera UI.
|
||||
*/
|
||||
export function useDocumentScanner() {
|
||||
const isNativeScanner = Capacitor.isNativePlatform()
|
||||
|
||||
const scanDocument = async ({ maxDocuments = 1, quality = 90, letUserAdjustCrop = true } = {}) => {
|
||||
if (!isNativeScanner) return { image: null, cancelled: false }
|
||||
|
||||
try {
|
||||
const { DocumentScanner } = await import('@capgo/capacitor-document-scanner')
|
||||
const { scannedImages } = await DocumentScanner.scanDocument({
|
||||
croppedImageQuality: quality,
|
||||
maxNumDocuments: maxDocuments,
|
||||
letUserAdjustCrop,
|
||||
})
|
||||
|
||||
if (!scannedImages?.length) return { image: null, cancelled: true }
|
||||
|
||||
const normalized = normalizeScannedImage(scannedImages[0])
|
||||
return { image: normalized, cancelled: false }
|
||||
} catch (e) {
|
||||
console.error('[DocumentScanner] scan failed:', e)
|
||||
return { image: null, cancelled: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
return { isNativeScanner, scanDocument }
|
||||
}
|
||||
92
src/hooks/useFileUpload.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import { useCallback } from 'react'
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||
|
||||
export const useFileUpload = ({ entityType = 'chore_attachment', entityId, draftId } = {}) => {
|
||||
const { showError } = useNotification()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async file => {
|
||||
if (!isPlusAccount(userProfile)) {
|
||||
showError({
|
||||
title: 'Plus Feature',
|
||||
message:
|
||||
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const compressionOptions = {
|
||||
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
|
||||
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
|
||||
useWebWorker: true,
|
||||
fileType: 'image/jpeg',
|
||||
}
|
||||
|
||||
const compressedFile = await imageCompression(file, compressionOptions)
|
||||
const compressedJpegFile = new File(
|
||||
[compressedFile],
|
||||
`${file.name.split('.')[0]}.jpg`,
|
||||
{ type: 'image/jpeg' },
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', compressedJpegFile)
|
||||
formData.append('entityType', entityType)
|
||||
if (entityId) formData.append('entityId', String(entityId))
|
||||
if (draftId) formData.append('draftId', draftId)
|
||||
|
||||
const response = await apiClient.upload('/assets/chore', formData)
|
||||
|
||||
if (response.status === 507) {
|
||||
showError({
|
||||
title: 'Storage Quota Exceeded',
|
||||
message: 'You have exceeded your quota for uploading files.',
|
||||
})
|
||||
return null
|
||||
} else if (response.status === 413) {
|
||||
showError({
|
||||
title: 'File Too Large',
|
||||
message: 'The file you are trying to upload is too large.',
|
||||
})
|
||||
return null
|
||||
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
|
||||
showError({
|
||||
title: 'Upgrade Required',
|
||||
message: 'Image uploads are only available for Plus accounts.',
|
||||
})
|
||||
return null
|
||||
} else if (response.status === 403) {
|
||||
showError({
|
||||
title: 'Permission Denied',
|
||||
message: 'You do not have permission to upload files.',
|
||||
})
|
||||
return null
|
||||
} else if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload image.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return resolvePhotoURL(data.url || data.sign)
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'An error occurred while processing the image.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
},
|
||||
[entityType, entityId, draftId, showError, userProfile],
|
||||
)
|
||||
|
||||
return { uploadFile, isPlus: isPlusAccount(userProfile) }
|
||||
}
|
||||
59
src/hooks/useFilter.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Generic client-side filter hook.
|
||||
*
|
||||
* @param {Array} data - the full list to filter
|
||||
* @param {Array} filterDefs - array of filter definitions (see FilterBar)
|
||||
* @returns {{ filteredData, activeFilters, setFilter, clearAll, activeFilterCount, hasActiveFilters }}
|
||||
*
|
||||
* Each filterDef must include:
|
||||
* id - unique string key
|
||||
* type - 'multi-select' | 'boolean'
|
||||
* filterFn - (item, filterValue) => boolean
|
||||
*/
|
||||
export const useFilter = (data, filterDefs) => {
|
||||
const [activeFilters, setActiveFilters] = useState({})
|
||||
|
||||
const setFilter = (filterId, value) => {
|
||||
setActiveFilters(prev => {
|
||||
const isEmpty =
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
(Array.isArray(value) && value.length === 0)
|
||||
|
||||
if (isEmpty) {
|
||||
const { [filterId]: _removed, ...rest } = prev
|
||||
return rest
|
||||
}
|
||||
return { ...prev, [filterId]: value }
|
||||
})
|
||||
}
|
||||
|
||||
const clearAll = () => setActiveFilters({})
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!data) return []
|
||||
if (!Object.keys(activeFilters).length) return data
|
||||
|
||||
return data.filter(item =>
|
||||
filterDefs.every(def => {
|
||||
const value = activeFilters[def.id]
|
||||
if (value === undefined || value === null) return true
|
||||
if (Array.isArray(value) && value.length === 0) return true
|
||||
return def.filterFn(item, value)
|
||||
}),
|
||||
)
|
||||
}, [data, activeFilters, filterDefs])
|
||||
|
||||
const activeFilterCount = Object.keys(activeFilters).length
|
||||
|
||||
return {
|
||||
filteredData,
|
||||
activeFilters,
|
||||
setFilter,
|
||||
clearAll,
|
||||
activeFilterCount,
|
||||
hasActiveFilters: activeFilterCount > 0,
|
||||
}
|
||||
}
|
||||
56
src/hooks/useStatusBar.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useColorScheme } from '@mui/joy'
|
||||
import { useEffect } from 'react'
|
||||
import statusBarManager from '../utils/StatusBarManager'
|
||||
|
||||
/**
|
||||
* Custom hook to manage status bar integration with Joy UI themes
|
||||
* This hook automatically syncs the status bar style with the current theme
|
||||
*/
|
||||
export const useStatusBar = () => {
|
||||
const { mode, systemMode } = useColorScheme()
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize status bar on mount
|
||||
const initializeStatusBar = async () => {
|
||||
await statusBarManager.initialize(mode)
|
||||
}
|
||||
|
||||
initializeStatusBar()
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
statusBarManager.cleanup()
|
||||
}
|
||||
}, [mode]) // Include mode dependency
|
||||
|
||||
useEffect(() => {
|
||||
// Update status bar when theme changes
|
||||
const updateStatusBarTheme = async () => {
|
||||
let resolvedTheme = mode
|
||||
|
||||
// Handle system mode by using the detected system theme
|
||||
if (mode === 'system') {
|
||||
resolvedTheme = systemMode || 'light'
|
||||
}
|
||||
|
||||
// Update the status bar with the resolved theme
|
||||
await statusBarManager.updateResolvedTheme(resolvedTheme)
|
||||
|
||||
// Also update the base theme for future reference
|
||||
await statusBarManager.setTheme(mode)
|
||||
|
||||
// Notify any custom listeners
|
||||
statusBarManager.notifyThemeChange(resolvedTheme)
|
||||
}
|
||||
|
||||
updateStatusBarTheme()
|
||||
}, [mode, systemMode]) // Update when either mode or systemMode changes
|
||||
|
||||
return {
|
||||
statusBarManager,
|
||||
currentTheme: mode,
|
||||
resolvedTheme: mode === 'system' ? systemMode : mode,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStatusBar
|
||||
@@ -8,7 +8,8 @@ import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../utils/SyncEngine'
|
||||
import { networkManager } from './NetworkManager'
|
||||
|
||||
const PENDING_POLL_MS = 30_000 // retry pending commands every 30s
|
||||
export const PENDING_POLL_MS = 30_000 // retry pending commands every 30s
|
||||
export const SERVER_PROBE_MS = 15_000 // probe server when marked unreachable but device has network
|
||||
const CACHE_REFRESH_MS = 5 * 60_000 // refresh IDB cache every 5 min while online
|
||||
|
||||
export function useSyncOnReconnect() {
|
||||
@@ -18,6 +19,7 @@ export function useSyncOnReconnect() {
|
||||
useEffect(() => {
|
||||
let pendingPollInterval
|
||||
let cacheRefreshInterval
|
||||
let serverProbeInterval
|
||||
let resumeListener
|
||||
let networkListener
|
||||
const handleVisibilityChange = () => {
|
||||
@@ -77,13 +79,27 @@ export function useSyncOnReconnect() {
|
||||
cacheRefreshInterval = setInterval(() => {
|
||||
runSync()
|
||||
}, CACHE_REFRESH_MS)
|
||||
|
||||
// 6. Probe server every 15s when server is unreachable but device has network
|
||||
serverProbeInterval = setInterval(async () => {
|
||||
if (!networkManager.isOnline && networkManager.deviceOnline) {
|
||||
await runSync()
|
||||
}
|
||||
}, SERVER_PROBE_MS)
|
||||
}
|
||||
|
||||
const runSync = async () => {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
const wasOffline = !networkManager.isOnline
|
||||
const didSync = await syncEngine.sync()
|
||||
if (didSync) {
|
||||
queryClient.invalidateQueries()
|
||||
// After recovery from server-unreachable, run a second pass to flush
|
||||
// any commands that were skipped while offline
|
||||
if (wasOffline && networkManager.isOnline) {
|
||||
const didSync2 = await syncEngine.sync()
|
||||
if (didSync2) queryClient.invalidateQueries()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +114,10 @@ export function useSyncOnReconnect() {
|
||||
clearInterval(cacheRefreshInterval)
|
||||
}
|
||||
|
||||
if (serverProbeInterval) {
|
||||
clearInterval(serverProbeInterval)
|
||||
}
|
||||
|
||||
if (networkListener) {
|
||||
networkManager.unregisterNetworkListener(networkListener)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CreateChore,
|
||||
DeleteChore,
|
||||
DeleteChoreHistory,
|
||||
GetChoreAttachments,
|
||||
GetChoreByID,
|
||||
GetChoreDetailById,
|
||||
GetChoreHistory,
|
||||
@@ -58,7 +59,8 @@ const mergePendingCreates = async chores => {
|
||||
}
|
||||
|
||||
const isNetworkError = error =>
|
||||
error instanceof TypeError && error.message === 'Failed to fetch'
|
||||
(error instanceof TypeError && error.message === 'Failed to fetch') ||
|
||||
error?.name === 'AbortError'
|
||||
|
||||
const buildOfflineChore = task => ({
|
||||
...task,
|
||||
@@ -191,13 +193,7 @@ export const useCreateChore = () => {
|
||||
if (!createdChore) {
|
||||
throw new Error('Failed to get created chore data')
|
||||
}
|
||||
// Successfully created the chore on the server, return the created chore
|
||||
// update the local chores cache with the new chore:
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData) return { res: [createdChore.res] }
|
||||
return { res: [...oldData.res, createdChore.res] }
|
||||
})
|
||||
return createdChore.res
|
||||
return { ...newTask, id: createdChore.res }
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
return queueOfflineCreate(newTask)
|
||||
@@ -258,7 +254,7 @@ export const useUpdateChore = () => {
|
||||
),
|
||||
}
|
||||
})
|
||||
return updatedChoreRes?.res || updatedChoreRes
|
||||
return updatedChoreRes?.res || updatedChore
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
return queueOfflineUpdate()
|
||||
@@ -577,7 +573,45 @@ export const useMarkChoreComplete = () => {
|
||||
})
|
||||
return { res: { _pending: 'complete' } }
|
||||
}
|
||||
return MarkChoreComplete(choreId, body, completedDate, performer)
|
||||
|
||||
const queueOfflineComplete = async () => {
|
||||
await commandQueue.enqueue(CommandType.COMPLETE_CHORE, choreId, {
|
||||
id: choreId,
|
||||
body,
|
||||
completedDate,
|
||||
performer,
|
||||
})
|
||||
await offlineDB.savePendingHistory({
|
||||
id: -Date.now(),
|
||||
choreId: Number(choreId),
|
||||
completedBy: body?.completedBy || 0,
|
||||
performedAt: completedDate || new Date().toISOString(),
|
||||
notes: body?.note || null,
|
||||
status: 1,
|
||||
points: 0,
|
||||
pending: true,
|
||||
})
|
||||
queryClient.setQueryData(['chores'], oldData => {
|
||||
if (!oldData) return oldData
|
||||
return {
|
||||
res: oldData.res.map(chore =>
|
||||
chore.id === choreId
|
||||
? { ...chore, _pending: 'complete' }
|
||||
: chore,
|
||||
),
|
||||
}
|
||||
})
|
||||
return { res: { _pending: 'complete' } }
|
||||
}
|
||||
|
||||
try {
|
||||
return await MarkChoreComplete(choreId, body, completedDate, performer)
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
return queueOfflineComplete()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
onSuccess: (_, { choreId }) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
@@ -608,7 +642,26 @@ export const useSkipChore = () => {
|
||||
})
|
||||
return { res: { _pending: 'skip' } }
|
||||
}
|
||||
return SkipChore(choreId)
|
||||
|
||||
try {
|
||||
return await SkipChore(choreId)
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
await commandQueue.enqueue(CommandType.SKIP_CHORE, choreId, {
|
||||
id: choreId,
|
||||
})
|
||||
queryClient.setQueryData(['chores'], oldData => {
|
||||
if (!oldData) return oldData
|
||||
return {
|
||||
res: oldData.res.map(chore =>
|
||||
chore.id === choreId ? { ...chore, _pending: 'skip' } : chore,
|
||||
),
|
||||
}
|
||||
})
|
||||
return { res: { _pending: 'skip' } }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
onSuccess: (_, choreId) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
@@ -644,3 +697,19 @@ export const useRejectChore = () => {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useChoreAttachments = (choreId, hasAttachments = true) => {
|
||||
return useQuery({
|
||||
queryKey: ['choreAttachments', choreId],
|
||||
queryFn: async () => {
|
||||
const response = await GetChoreAttachments(choreId)
|
||||
if (response && response.ok) {
|
||||
return await response.json()
|
||||
}
|
||||
throw new Error('Failed to fetch attachments')
|
||||
},
|
||||
enabled: !!choreId && hasAttachments,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
gcTime: 15 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
72
src/service/AIPromptCache.js
Normal file
@@ -0,0 +1,72 @@
|
||||
const ENABLED_KEY = 'ai_prompt_cache_enabled'
|
||||
const ENTRY_PREFIX = 'ai_prompt_cache_'
|
||||
const INDEX_KEY = 'ai_prompt_cache_index'
|
||||
|
||||
function djb2(str) {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash) ^ str.charCodeAt(i)
|
||||
hash = hash >>> 0
|
||||
}
|
||||
return hash.toString(36)
|
||||
}
|
||||
|
||||
export function isCacheEnabled() {
|
||||
try {
|
||||
return localStorage.getItem(ENABLED_KEY) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function setCacheEnabled(enabled) {
|
||||
try {
|
||||
localStorage.setItem(ENABLED_KEY, String(enabled))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function hashContent(content) {
|
||||
return djb2(typeof content === 'string' ? content : JSON.stringify(content))
|
||||
}
|
||||
|
||||
function getIndex() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(INDEX_KEY) || '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function getCached(hash) {
|
||||
if (!isCacheEnabled()) return null
|
||||
try {
|
||||
const raw = localStorage.getItem(ENTRY_PREFIX + hash)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setCached(hash, value) {
|
||||
if (!isCacheEnabled()) return
|
||||
try {
|
||||
localStorage.setItem(ENTRY_PREFIX + hash, JSON.stringify(value))
|
||||
const index = getIndex()
|
||||
if (!index.includes(hash)) {
|
||||
index.push(hash)
|
||||
localStorage.setItem(INDEX_KEY, JSON.stringify(index))
|
||||
}
|
||||
} catch { /* storage full, ignore */ }
|
||||
}
|
||||
|
||||
export function getCacheStats() {
|
||||
return { count: getIndex().length }
|
||||
}
|
||||
|
||||
export function clearCache() {
|
||||
const index = getIndex()
|
||||
index.forEach(h => {
|
||||
try { localStorage.removeItem(ENTRY_PREFIX + h) } catch { /* ignore */ }
|
||||
})
|
||||
try { localStorage.removeItem(INDEX_KEY) } catch { /* ignore */ }
|
||||
}
|
||||
141
src/service/LocalAIService.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { getCached, hashContent, setCached } from './AIPromptCache'
|
||||
|
||||
// Native-only local AI service using @capacitor/local-llm.
|
||||
// On web, all methods return 'unavailable' / null — no WebLLM.
|
||||
class LocalAIService {
|
||||
constructor() {
|
||||
this._availability = null
|
||||
this._sessionId = 'donetick-summary'
|
||||
this._warmedUp = false
|
||||
}
|
||||
|
||||
get isNative() {
|
||||
return Capacitor.isNativePlatform()
|
||||
}
|
||||
|
||||
async checkAvailability() {
|
||||
if (!this.isNative) {
|
||||
this._availability = 'unavailable'
|
||||
return 'unavailable'
|
||||
}
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
const { status } = await LocalLLM.systemAvailability()
|
||||
this._availability = status
|
||||
return status
|
||||
} catch (e) {
|
||||
this._availability = 'unavailable'
|
||||
return 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
if (this._availability !== null) return this._availability
|
||||
return this.checkAvailability()
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
return (await this.getStatus()) === 'available'
|
||||
}
|
||||
|
||||
resetAvailability() {
|
||||
this._availability = null
|
||||
}
|
||||
|
||||
async download(onStatusChange) {
|
||||
if (!this.isNative) return
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
if (onStatusChange) {
|
||||
LocalLLM.addListener('systemAvailabilityChange', ({ status }) => {
|
||||
this._availability = status
|
||||
onStatusChange(status)
|
||||
})
|
||||
}
|
||||
await LocalLLM.download()
|
||||
} catch {
|
||||
// download not available on iOS, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async warmup() {
|
||||
if (this._warmedUp || !this.isNative) return
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
await LocalLLM.warmup({ sessionId: this._sessionId })
|
||||
this._warmedUp = true
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
async _nativePrompt(text) {
|
||||
await this.warmup()
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
const { text: out } = await LocalLLM.prompt({ prompt: text, sessionId: this._sessionId })
|
||||
return out?.trim() || null
|
||||
} finally {
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
await LocalLLM.endSession({ sessionId: this._sessionId })
|
||||
this._warmedUp = false
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Plain chat — no tools. Returns answer string or null.
|
||||
async plainChat(messages) {
|
||||
const available = await this.isAvailable()
|
||||
if (!available) return null
|
||||
|
||||
const cacheHash = hashContent(['plain', ...messages])
|
||||
const cached = getCached(cacheHash)
|
||||
if (cached) return cached
|
||||
|
||||
if (!this.isNative) return null
|
||||
|
||||
try {
|
||||
const systemMsg = messages.find(m => m.role === 'system')?.content || ''
|
||||
const userMsg = messages.find(m => m.role === 'user')?.content || ''
|
||||
const result = await this._nativePrompt(`${systemMsg}\n\nUser: ${userMsg}\nAssistant:`)
|
||||
if (result) setCached(cacheHash, result)
|
||||
return result
|
||||
} catch (e) {
|
||||
console.error('[LocalAI] plainChat() failed:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the summary string or null if LLM is unavailable
|
||||
async summarize(prompt) {
|
||||
const available = await this.isAvailable()
|
||||
if (!available) return null
|
||||
|
||||
const cacheHash = hashContent(prompt)
|
||||
const cached = getCached(cacheHash)
|
||||
if (cached) return cached
|
||||
|
||||
if (!this.isNative) return null
|
||||
|
||||
try {
|
||||
await this.warmup()
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
const { text } = await LocalLLM.prompt({ prompt, sessionId: this._sessionId })
|
||||
const result = text?.trim() || null
|
||||
if (result) setCached(cacheHash, result)
|
||||
return result
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
await LocalLLM.endSession({ sessionId: this._sessionId })
|
||||
this._warmedUp = false
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const localAIService = new LocalAIService()
|
||||
@@ -1,11 +1,136 @@
|
||||
import { CapacitorNfc } from '@capgo/capacitor-nfc'
|
||||
|
||||
// Encodes a URL into an NDEF URI record (TNF=0x01, type='U')
|
||||
const buildUriRecord = url => {
|
||||
const encoder = new TextEncoder()
|
||||
let prefixByte = 0x00
|
||||
let uriStr = url
|
||||
if (url.startsWith('https://')) {
|
||||
prefixByte = 0x04
|
||||
uriStr = url.slice(8)
|
||||
} else if (url.startsWith('http://')) {
|
||||
prefixByte = 0x03
|
||||
uriStr = url.slice(7)
|
||||
}
|
||||
return {
|
||||
tnf: 0x01,
|
||||
type: [0x55],
|
||||
id: [],
|
||||
payload: [prefixByte, ...Array.from(encoder.encode(uriStr))],
|
||||
}
|
||||
}
|
||||
|
||||
// Decodes a URL from an NDEF URI record payload. Returns null if not a URI record.
|
||||
export const decodeNdefUrl = record => {
|
||||
if (!record || record.tnf !== 0x01) return null
|
||||
if (record.type.length !== 1 || record.type[0] !== 0x55) return null
|
||||
const payload = record.payload
|
||||
if (!payload || payload.length === 0) return null
|
||||
const prefixes = [
|
||||
'',
|
||||
'http://www.',
|
||||
'https://www.',
|
||||
'http://',
|
||||
'https://',
|
||||
'tel:',
|
||||
'mailto:',
|
||||
]
|
||||
const prefix = prefixes[payload[0]] ?? ''
|
||||
const uri = new TextDecoder().decode(new Uint8Array(payload.slice(1)))
|
||||
return prefix + uri
|
||||
}
|
||||
|
||||
// Starts a native NFC write session. Calls onWaiting once scanning is active,
|
||||
// then onSuccess or onError when the write completes. Returns a cancel function.
|
||||
export const startNativeNFCWrite = async (url, { onWaiting, onSuccess, onError }) => {
|
||||
let listener = null
|
||||
let done = false
|
||||
|
||||
const cleanup = async () => {
|
||||
if (listener) {
|
||||
await listener.remove()
|
||||
listener = null
|
||||
}
|
||||
await CapacitorNfc.stopScanning().catch(() => {})
|
||||
}
|
||||
|
||||
try {
|
||||
listener = await CapacitorNfc.addListener('nfcEvent', async () => {
|
||||
if (done) return
|
||||
done = true
|
||||
try {
|
||||
await CapacitorNfc.write({ records: [buildUriRecord(url)] })
|
||||
await cleanup()
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
await cleanup()
|
||||
onError(err.message || 'Failed to write to NFC tag')
|
||||
}
|
||||
})
|
||||
|
||||
await CapacitorNfc.startScanning({
|
||||
alertMessage: 'Hold your device near the NFC tag to write',
|
||||
invalidateAfterFirstRead: true,
|
||||
// Without FLAG_READER_SKIP_NDEF_CHECK (0x80), Android enumerates
|
||||
// Ndef/NdefFormatable tech so the plugin can format blank tags on write.
|
||||
androidReaderModeFlags: 0x0f, // NFC_A | NFC_B | NFC_F | NFC_V
|
||||
})
|
||||
onWaiting()
|
||||
return cleanup
|
||||
} catch (err) {
|
||||
await cleanup()
|
||||
onError(err.message || 'Failed to start NFC session')
|
||||
return async () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Starts a native NFC scan session for reading. Calls onTag(url) when a URL
|
||||
// NDEF record is found, or onError on failure. Returns a cancel function.
|
||||
export const startNativeScan = async ({ onTag, onError }) => {
|
||||
let listener = null
|
||||
let done = false
|
||||
|
||||
const cleanup = async () => {
|
||||
if (listener) {
|
||||
await listener.remove()
|
||||
listener = null
|
||||
}
|
||||
await CapacitorNfc.stopScanning().catch(() => {})
|
||||
}
|
||||
|
||||
try {
|
||||
listener = await CapacitorNfc.addListener('nfcEvent', async event => {
|
||||
if (done) return
|
||||
const records = event.tag?.ndefMessage ?? []
|
||||
for (const record of records) {
|
||||
const url = decodeNdefUrl(record)
|
||||
if (url) {
|
||||
done = true
|
||||
await cleanup()
|
||||
onTag(url)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await CapacitorNfc.startScanning({
|
||||
alertMessage: 'Hold your device near the NFC tag',
|
||||
invalidateAfterFirstRead: true,
|
||||
})
|
||||
return cleanup
|
||||
} catch (err) {
|
||||
await cleanup()
|
||||
onError(err.message || 'Failed to start NFC session')
|
||||
return async () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy default export for web/PWA (NDEFReader API)
|
||||
const writeToNFC = async url => {
|
||||
if ('NDEFReader' in window) {
|
||||
try {
|
||||
const ndef = new window.NDEFReader()
|
||||
await ndef.write({
|
||||
records: [{ recordType: 'url', data: url }],
|
||||
})
|
||||
alert('URL written to NFC tag successfully!')
|
||||
await ndef.write({ records: [{ recordType: 'url', data: url }] })
|
||||
} catch (error) {
|
||||
console.error('Error writing to NFC tag:', error)
|
||||
alert('Error writing to NFC tag. Please try again.')
|
||||
|
||||
127
src/styles/safe-area.css
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Safe Area CSS Utilities
|
||||
* These utilities provide consistent safe area handling across the app
|
||||
* using CSS custom properties set by StatusBarManager
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Fallback values for safe area insets when not on native platforms */
|
||||
--safe-area-inset-top: 0px;
|
||||
--safe-area-inset-right: 0px;
|
||||
--safe-area-inset-bottom: 0px;
|
||||
--safe-area-inset-left: 0px;
|
||||
}
|
||||
|
||||
/* Utility classes for safe area handling */
|
||||
.safe-area-top {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-area-right {
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-area-left {
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
}
|
||||
|
||||
.safe-area-x {
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
|
||||
.safe-area-y {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-area-all {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
}
|
||||
|
||||
/* Margin variants */
|
||||
.safe-margin-top {
|
||||
margin-top: var(--safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-margin-right {
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
}
|
||||
|
||||
.safe-margin-bottom {
|
||||
margin-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-margin-left {
|
||||
margin-left: var(--safe-area-inset-left);
|
||||
}
|
||||
|
||||
.safe-margin-x {
|
||||
margin-left: var(--safe-area-inset-left);
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
}
|
||||
|
||||
.safe-margin-y {
|
||||
margin-top: var(--safe-area-inset-top);
|
||||
margin-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-margin-all {
|
||||
margin-top: var(--safe-area-inset-top);
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
margin-bottom: var(--safe-area-inset-bottom);
|
||||
margin-left: var(--safe-area-inset-left);
|
||||
}
|
||||
|
||||
/* Height utilities that account for safe areas */
|
||||
.min-h-screen-safe {
|
||||
min-height: calc(100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.h-screen-safe {
|
||||
height: calc(100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* Top positioning that accounts for safe area */
|
||||
.top-safe {
|
||||
top: var(--safe-area-inset-top);
|
||||
}
|
||||
|
||||
/* Bottom positioning that accounts for safe area */
|
||||
.bottom-safe {
|
||||
bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* Fixed positioning utilities that respect safe areas */
|
||||
.fixed-top-safe {
|
||||
position: fixed;
|
||||
top: var(--safe-area-inset-top);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1030;
|
||||
}
|
||||
|
||||
.fixed-bottom-safe {
|
||||
position: fixed;
|
||||
bottom: var(--safe-area-inset-bottom);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1030;
|
||||
}
|
||||
|
||||
/* Container that provides full safe area coverage */
|
||||
.container-safe {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { API_URL } from '../Config'
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import { logout, RefreshToken } from './Fetcher'
|
||||
import {
|
||||
clearAllTokens,
|
||||
@@ -17,7 +18,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async init(force = false) {
|
||||
if (!force && this.initPromise) {
|
||||
if (this.initPromise && !force) {
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
@@ -25,7 +26,9 @@ class ApiClient {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
this.initPromise = this._doInit()
|
||||
this.initPromise = this._doInit().finally(() => {
|
||||
this.initPromise = null
|
||||
})
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
@@ -144,15 +147,22 @@ class ApiClient {
|
||||
async request(endpoint, options = {}) {
|
||||
await this.init()
|
||||
const url = `${this.customServerURL}${endpoint}`
|
||||
|
||||
// Abort after 10s so a dead/unreachable server doesn't hang the UI
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10_000)
|
||||
|
||||
const config = {
|
||||
// credentials: 'include',
|
||||
...options,
|
||||
headers: this.getHeaders(options.headers),
|
||||
signal: options.signal ?? controller.signal,
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Initial Request
|
||||
let response = await fetch(url, config)
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
// 2. Check for 401 (Unauthorized)
|
||||
if (response.status === 401) {
|
||||
@@ -220,6 +230,9 @@ class ApiClient {
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
// fetch() threw = network-level failure or timeout — mark server unreachable
|
||||
networkManager.setServerUnreachable()
|
||||
console.error('Request failed', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -25,6 +25,67 @@ export const ChoreStatus = Object.freeze({
|
||||
PAUSED: 2,
|
||||
PENDING_APPROVAL: 3,
|
||||
})
|
||||
|
||||
const getDateGroupKey = dueDate =>
|
||||
moment(dueDate).startOf('day').format('YYYY-MM-DD')
|
||||
|
||||
const getDateGroupName = dateKey =>
|
||||
moment(dateKey, 'YYYY-MM-DD').format('dddd, MMM D')
|
||||
|
||||
const getDateGroupColor = dateKey => {
|
||||
const today = moment().startOf('day')
|
||||
const tomorrow = moment().add(1, 'day').startOf('day')
|
||||
const groupDate = moment(dateKey, 'YYYY-MM-DD')
|
||||
|
||||
if (groupDate.isBefore(today)) {
|
||||
return TASK_COLOR.OVERDUE
|
||||
}
|
||||
if (groupDate.isSame(today)) {
|
||||
return TASK_COLOR.TODAY
|
||||
}
|
||||
if (groupDate.isSame(tomorrow)) {
|
||||
return TASK_COLOR.TOMORROW
|
||||
}
|
||||
if (groupDate.isBefore(moment(today).add(8, 'days'))) {
|
||||
return TASK_COLOR.NEXT_7_DAYS
|
||||
}
|
||||
if (groupDate.isSame(today, 'month')) {
|
||||
return TASK_COLOR.LATER_THIS_MONTH
|
||||
}
|
||||
return TASK_COLOR.FUTURE
|
||||
}
|
||||
|
||||
const buildActualDateGroups = chores => {
|
||||
const groupedByDate = {}
|
||||
const anytime = []
|
||||
|
||||
chores.forEach(chore => {
|
||||
if (!chore.nextDueDate) {
|
||||
anytime.push(chore)
|
||||
return
|
||||
}
|
||||
|
||||
const dateKey = getDateGroupKey(chore.nextDueDate)
|
||||
if (!groupedByDate[dateKey]) {
|
||||
groupedByDate[dateKey] = []
|
||||
}
|
||||
groupedByDate[dateKey].push(chore)
|
||||
})
|
||||
|
||||
const dateGroups = Object.keys(groupedByDate)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
moment(a, 'YYYY-MM-DD').valueOf() - moment(b, 'YYYY-MM-DD').valueOf(),
|
||||
)
|
||||
.map(dateKey => ({
|
||||
name: getDateGroupName(dateKey),
|
||||
content: groupedByDate[dateKey],
|
||||
color: getDateGroupColor(dateKey),
|
||||
}))
|
||||
|
||||
return { dateGroups, anytime }
|
||||
}
|
||||
|
||||
export const ChoresGrouper = (groupBy, chores, filter) => {
|
||||
if (filter) {
|
||||
chores = chores.filter(chore => filter(chore))
|
||||
@@ -34,7 +95,7 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
|
||||
chores.sort(ChoreSorter)
|
||||
var groups = []
|
||||
switch (groupBy) {
|
||||
case 'default':
|
||||
case 'default': {
|
||||
// same as due_date but hide empty groups: and if status is 1 or 2 have seperated catigory as Started:
|
||||
var groupRaw = {
|
||||
PendingApproval: [],
|
||||
@@ -147,82 +208,21 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'due_date':
|
||||
var groupRaw = {
|
||||
Today: [],
|
||||
Tomorrow: [],
|
||||
'Next 7 Days': [],
|
||||
'Later This Month': [],
|
||||
Future: [],
|
||||
Overdue: [],
|
||||
Anytime: [],
|
||||
}
|
||||
chores.forEach(chore => {
|
||||
if (chore.nextDueDate === null) {
|
||||
groupRaw['Anytime'].push(chore)
|
||||
} else if (new Date(chore.nextDueDate) < new Date()) {
|
||||
groupRaw['Overdue'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate).toDateString() ===
|
||||
new Date().toDateString()
|
||||
) {
|
||||
groupRaw['Today'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate).toDateString() ===
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000).toDateString()
|
||||
) {
|
||||
groupRaw['Tomorrow'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate) <
|
||||
new Date(Date.now() + 8 * 24 * 60 * 60 * 1000) &&
|
||||
new Date(chore.nextDueDate) >
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
) {
|
||||
groupRaw['Next 7 Days'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate).getMonth() === new Date().getMonth() &&
|
||||
new Date(chore.nextDueDate).getFullYear() === new Date().getFullYear()
|
||||
) {
|
||||
groupRaw['Later This Month'].push(chore)
|
||||
} else {
|
||||
groupRaw['Future'].push(chore)
|
||||
}
|
||||
})
|
||||
groups = [
|
||||
{
|
||||
name: 'Overdue',
|
||||
content: groupRaw['Overdue'],
|
||||
color: TASK_COLOR.OVERDUE,
|
||||
},
|
||||
{ name: 'Today', content: groupRaw['Today'], color: TASK_COLOR.TODAY },
|
||||
{
|
||||
name: 'Tomorrow',
|
||||
content: groupRaw['Tomorrow'],
|
||||
color: TASK_COLOR.TOMORROW,
|
||||
},
|
||||
{
|
||||
name: 'Next 7 Days',
|
||||
content: groupRaw['Next 7 Days'],
|
||||
color: TASK_COLOR.NEXT_7_DAYS,
|
||||
},
|
||||
{
|
||||
name: 'Later This Month',
|
||||
content: groupRaw['Later This Month'],
|
||||
color: TASK_COLOR.LATER_THIS_MONTH,
|
||||
},
|
||||
{
|
||||
name: 'Future',
|
||||
content: groupRaw['Future'],
|
||||
color: TASK_COLOR.FUTURE,
|
||||
},
|
||||
{
|
||||
case 'due_date': {
|
||||
var { dateGroups: dueDateGroups, anytime: dueAnytime } =
|
||||
buildActualDateGroups(chores)
|
||||
groups = [...dueDateGroups]
|
||||
if (dueAnytime.length > 0) {
|
||||
groups.push({
|
||||
name: 'Anytime',
|
||||
content: groupRaw['Anytime'],
|
||||
content: dueAnytime,
|
||||
color: TASK_COLOR.ANYTIME,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'priority':
|
||||
groupRaw = {
|
||||
p1: [],
|
||||
|
||||
@@ -728,6 +728,30 @@ const DeleteUser = (password, confirmation, transferOptions = []) => {
|
||||
})
|
||||
}
|
||||
|
||||
const UploadChoreAttachment = (file, entityType, { entityId, draftId } = {}) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('entityType', entityType)
|
||||
if (entityId != null) formData.append('entityId', String(entityId))
|
||||
if (draftId != null) formData.append('draftId', draftId)
|
||||
return apiClient.upload('/assets/chore', formData)
|
||||
}
|
||||
|
||||
const GetChoreAttachments = choreId => {
|
||||
return Fetch(`/chores/${choreId}/attachments`, {
|
||||
method: 'GET',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
const DeleteChoreAttachment = (choreId, filePath) => {
|
||||
return Fetch(`/chores/${choreId}/attachments`, {
|
||||
method: 'DELETE',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({ file_path: filePath }),
|
||||
})
|
||||
}
|
||||
|
||||
const CreateBackup = (encryptionKey, includeAssets = true, backupName = '') => {
|
||||
return Fetch(`/backup/create`, {
|
||||
method: 'POST',
|
||||
@@ -933,6 +957,9 @@ const TrackFilterUsage = id => {
|
||||
|
||||
export {
|
||||
AcceptCircleMemberRequest,
|
||||
DeleteChoreAttachment,
|
||||
GetChoreAttachments,
|
||||
UploadChoreAttachment,
|
||||
ApproveChore,
|
||||
ArchiveChore,
|
||||
CancelSubscription,
|
||||
|
||||
@@ -10,9 +10,114 @@ const resolvePhotoURL = url => {
|
||||
if (url.startsWith('http') || url.startsWith('https')) {
|
||||
return url
|
||||
}
|
||||
if (url.startsWith('assets')) {
|
||||
return apiClient.getAssetURL(url)
|
||||
}
|
||||
return url
|
||||
return apiClient.getAssetURL(url)
|
||||
}
|
||||
export { isPlusAccount, resolvePhotoURL }
|
||||
|
||||
// Detect cloud storage pre-signed URLs (S3, GCS, Azure) that carry expiry params.
|
||||
const isCloudSignedUrl = url => {
|
||||
if (!url) return false
|
||||
try {
|
||||
return (
|
||||
url.includes('X-Amz-Signature') ||
|
||||
url.includes('X-Amz-Expires') ||
|
||||
url.includes('X-Goog-Expires') ||
|
||||
url.includes('expires')
|
||||
)
|
||||
} catch(e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the storage key from a cloud signed URL so we can route it through
|
||||
// the backend proxy (which re-signs on every request and never expires).
|
||||
//
|
||||
// Handles:
|
||||
// Virtual-hosted S3: https://{bucket}.s3[.region].amazonaws.com/{key}?...
|
||||
// Path-style S3: https://s3[.region].amazonaws.com/{bucket}/{key}?...
|
||||
// Cloudflare R2: https://{bucket}.{accountid}.r2.cloudflarestorage.com/{key}?...
|
||||
// GCS: https://storage.googleapis.com/{bucket}/{key}?...
|
||||
// Azure Blob: https://{account}.blob.core.windows.net/{container}/{blob}?...
|
||||
//
|
||||
// The app stores files under an "assets/" prefix in the bucket but the backend
|
||||
// proxy already mounts at /assets/, so we strip that leading segment when present.
|
||||
const extractStorageKey = url => {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const host = u.hostname
|
||||
const rawPath = u.pathname.replace(/^\//, '')
|
||||
|
||||
let key
|
||||
|
||||
if (host.endsWith('.r2.cloudflarestorage.com')) {
|
||||
// Virtual-hosted R2: bucket is in the host, key is the full path
|
||||
key = rawPath
|
||||
} else if (host.endsWith('.amazonaws.com')) {
|
||||
if (host.startsWith('s3') || host.includes('.s3.')) {
|
||||
// Path-style S3: first segment is the bucket — strip it
|
||||
key = rawPath.split('/').slice(1).join('/')
|
||||
} else {
|
||||
// Virtual-hosted S3: bucket is in the host, path is the key
|
||||
key = rawPath
|
||||
}
|
||||
} else if (host === 'storage.googleapis.com') {
|
||||
// First segment is the bucket
|
||||
key = rawPath.split('/').slice(1).join('/')
|
||||
} else if (host.endsWith('.blob.core.windows.net')) {
|
||||
// First segment is the container name
|
||||
key = rawPath.split('/').slice(1).join('/')
|
||||
} else {
|
||||
key = rawPath
|
||||
}
|
||||
|
||||
// The bucket stores files under an "assets/" prefix; the backend /assets/
|
||||
// endpoint already adds that prefix, so strip it to avoid duplication.
|
||||
if (key.startsWith('assets/')) {
|
||||
key = key.slice('assets/'.length)
|
||||
}
|
||||
|
||||
return key || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Scan an HTML string for <img> tags whose src is a cloud signed URL and
|
||||
// replace them with backend proxy URLs (which generate fresh signed URLs on
|
||||
// each request). Returns the patched HTML, or the original if nothing changed.
|
||||
const refreshSignedUrlsInHtml = html => {
|
||||
if (!html) return html
|
||||
if (
|
||||
!html.includes('dt-data-path') &&
|
||||
!html.includes('X-Amz-') &&
|
||||
!html.includes('X-Goog-') &&
|
||||
!html.includes('sig') &&
|
||||
!html.includes('.blob.core.windows.net')
|
||||
) {
|
||||
return html
|
||||
}
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
const imgs = doc.querySelectorAll('img[src]')
|
||||
let changed = false
|
||||
|
||||
imgs.forEach(img => {
|
||||
const stablePath = img.getAttribute('dt-data-path')
|
||||
const src = img.getAttribute('src')
|
||||
let nextSrc = src
|
||||
|
||||
if (stablePath) {
|
||||
nextSrc = resolvePhotoURL(stablePath)
|
||||
} else if (isCloudSignedUrl(src)) {
|
||||
nextSrc = resolvePhotoURL(extractStorageKey(src))
|
||||
}
|
||||
|
||||
if (nextSrc && nextSrc !== src) {
|
||||
img.setAttribute('src', nextSrc)
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
|
||||
return changed ? doc.body.innerHTML : html
|
||||
}
|
||||
|
||||
export { isPlusAccount, refreshSignedUrlsInHtml, resolvePhotoURL }
|
||||
|
||||
@@ -26,17 +26,32 @@ class SQLiteBackend {
|
||||
constructor() {
|
||||
this.db = null
|
||||
this.initialized = false
|
||||
this._initPromise = null
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.initialized) return
|
||||
// Return the in-flight promise if init is already underway (prevents double createConnection)
|
||||
if (this._initPromise) return this._initPromise
|
||||
|
||||
this.db = await CapacitorSQLite.createConnection({
|
||||
database: DB_NAME,
|
||||
version: DB_VERSION,
|
||||
encrypted: false,
|
||||
mode: 'no-encryption',
|
||||
this._initPromise = this._doInit().finally(() => {
|
||||
this._initPromise = null
|
||||
})
|
||||
return this._initPromise
|
||||
}
|
||||
|
||||
async _doInit() {
|
||||
try {
|
||||
this.db = await CapacitorSQLite.createConnection({
|
||||
database: DB_NAME,
|
||||
version: DB_VERSION,
|
||||
encrypted: false,
|
||||
mode: 'no-encryption',
|
||||
})
|
||||
} catch (err) {
|
||||
// Connection already open (e.g. React StrictMode double-mount) — reuse it
|
||||
if (!err?.message?.includes('already exists')) throw err
|
||||
}
|
||||
await CapacitorSQLite.open({ database: DB_NAME })
|
||||
|
||||
await CapacitorSQLite.execute({
|
||||
|
||||
@@ -26,7 +26,7 @@ class StatusBarManager {
|
||||
}
|
||||
|
||||
try {
|
||||
// Configure basic status bar settings - use overlay: true for precise control
|
||||
// Configure basic status bar settings
|
||||
await StatusBar.setOverlaysWebView({ overlay: false })
|
||||
await StatusBar.show()
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ class SyncEngine {
|
||||
// Step 3: Delta sync from server
|
||||
await this._deltaSync()
|
||||
|
||||
// Sync succeeded — server is reachable (only sync success restores online status)
|
||||
networkManager.setServerReachable()
|
||||
this._notify({ syncing: false, lastSync: Date.now() })
|
||||
return true
|
||||
} catch (err) {
|
||||
@@ -182,7 +184,7 @@ class SyncEngine {
|
||||
let hasMore = true
|
||||
let currentCursor = cursor
|
||||
|
||||
while (hasMore && networkManager.isOnline) {
|
||||
while (hasMore && networkManager.deviceOnline) {
|
||||
// Use apiClient.get which handles auth and returns a fetch Response
|
||||
const response = await apiClient.get(
|
||||
`/sync/changes?since=${currentCursor}`,
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { GetUserProfile } from '../../utils/Fetcher'
|
||||
import { saveTokens } from '../../utils/TokenStorage'
|
||||
import MFAVerificationModal from './MFAVerificationModal'
|
||||
|
||||
const AuthenticationLoading = () => {
|
||||
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
|
||||
@@ -17,6 +19,8 @@ const AuthenticationLoading = () => {
|
||||
const [message, setMessage] = useState('Authenticating')
|
||||
const [subMessage, setSubMessage] = useState('Please wait')
|
||||
const [status, setStatus] = useState('pending')
|
||||
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
||||
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
||||
const { provider } = useParams()
|
||||
useEffect(() => {
|
||||
if (provider === 'oauth2' && !hasCalledHandleOAuth2.current) {
|
||||
@@ -43,6 +47,29 @@ const AuthenticationLoading = () => {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleMFASuccess = async data => {
|
||||
await saveTokens({
|
||||
accessToken: data.token,
|
||||
accessTokenExpiry: data.expire,
|
||||
refreshToken: data.refresh_token,
|
||||
refreshTokenExpiry: data.refresh_token_expiry,
|
||||
})
|
||||
|
||||
setMfaModalOpen(false)
|
||||
setMfaSessionToken('')
|
||||
|
||||
getUserProfileAndNavigateToHome()
|
||||
}
|
||||
|
||||
const handleMFAClose = () => {
|
||||
setMfaModalOpen(false)
|
||||
setMfaSessionToken('')
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Two-factor authentication was cancelled')
|
||||
setStatus('error')
|
||||
}
|
||||
|
||||
const handleOAuth2 = async () => {
|
||||
// get provider from params:
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
@@ -64,37 +91,71 @@ const AuthenticationLoading = () => {
|
||||
const redirectURI = Capacitor.isNativePlatform()
|
||||
? 'donetick://auth/oauth2'
|
||||
: `${window.location.origin}/auth/oauth2`
|
||||
fetch(`${baseURL}/auth/oauth2/callback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
state: returnedState,
|
||||
redirect_uri: redirectURI,
|
||||
}),
|
||||
}).then(response => {
|
||||
if (response.status === 200) {
|
||||
return response.json().then(data => {
|
||||
localStorage.setItem('token', data.token)
|
||||
localStorage.setItem('token_expiry', data.expire)
|
||||
try {
|
||||
const response = await fetch(`${baseURL}/auth/oauth2/callback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
state: returnedState,
|
||||
redirect_uri: redirectURI,
|
||||
}),
|
||||
})
|
||||
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl) {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
} else {
|
||||
getUserProfileAndNavigateToHome()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
if (!response.ok) {
|
||||
console.error('Authentication failed')
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Please try again')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.mfaRequired) {
|
||||
if (!data.sessionToken) {
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('MFA session is missing. Please try again')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
setMfaSessionToken(data.sessionToken)
|
||||
setMfaModalOpen(true)
|
||||
setMessage('Two-Factor Authentication Required')
|
||||
setSubMessage('Please verify your login to continue')
|
||||
return
|
||||
}
|
||||
|
||||
if (!data.token && !data.access_token) {
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('No valid authentication token returned')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
await saveTokens({
|
||||
accessToken: data.token || data.access_token,
|
||||
accessTokenExpiry: data.expire || data.access_token_expiry,
|
||||
refreshToken: data.refresh_token,
|
||||
refreshTokenExpiry: data.refresh_token_expiry,
|
||||
})
|
||||
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl) {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
} else {
|
||||
getUserProfileAndNavigateToHome()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Authentication request failed', error)
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Please try again')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +199,17 @@ const AuthenticationLoading = () => {
|
||||
<Link to='/login'>Go back Login</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<MFAVerificationModal
|
||||
open={mfaModalOpen}
|
||||
onClose={handleMFAClose}
|
||||
sessionToken={mfaSessionToken}
|
||||
onSuccess={handleMFASuccess}
|
||||
onError={() => {
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Two-factor authentication failed. Please try again')
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { Box, Button, Container, Input, Sheet, Typography } from '@mui/joy'
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
|
||||
import WifiIcon from '@mui/icons-material/Wifi'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Input,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { API_URL } from '../../Config'
|
||||
import Logo from '../../Logo'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
|
||||
const CONNECTION_TIMEOUT_MS = 8000
|
||||
|
||||
const LoginSettings = () => {
|
||||
const Navigate = useNavigate()
|
||||
const { refetch: refetchResource } = useResource()
|
||||
const [serverURL, setServerURL] = React.useState('')
|
||||
const { showError } = useNotification()
|
||||
const [status, setStatus] = React.useState('idle') // 'idle' | 'testing' | 'success' | 'error'
|
||||
const [errorMessage, setErrorMessage] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
Preferences.get({ key: 'customServerUrl' }).then(result => {
|
||||
@@ -19,10 +34,95 @@ const LoginSettings = () => {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isValidServerURL = () => {
|
||||
return serverURL.match(/^(http|https):\/\/[^ "]+$/)
|
||||
const isValidURL = url => {
|
||||
return /^(http|https):\/\/[^ "]+$/.test(url.trim())
|
||||
}
|
||||
|
||||
const testConnection = async url => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
CONNECTION_TIMEOUT_MS,
|
||||
)
|
||||
try {
|
||||
const testURL = url.replace(/\/+$/, '') + '/api/v1/resource'
|
||||
const response = await fetch(testURL, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
// Any HTTP response (even 401/404) means the server is reachable
|
||||
if (response.status < 500) {
|
||||
return { ok: true }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message: `Server responded with error ${response.status}. Please check your Donetick server.`,
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId)
|
||||
if (err.name === 'AbortError') {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Connection timed out after ${CONNECTION_TIMEOUT_MS / 1000}s. Check the URL and ensure the server is running.`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
'Unable to reach the server. Check the URL, port, and network connection.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedURL = serverURL.trim()
|
||||
|
||||
if (trimmedURL === '') {
|
||||
await Preferences.set({ key: 'customServerUrl', value: API_URL })
|
||||
Navigate('/login')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidURL(trimmedURL)) {
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
'Invalid URL format. Include the protocol (http:// or https://) and port if needed.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('testing')
|
||||
setErrorMessage('')
|
||||
|
||||
const result = await testConnection(trimmedURL)
|
||||
|
||||
if (!result.ok) {
|
||||
setStatus('error')
|
||||
setErrorMessage(result.message)
|
||||
return
|
||||
}
|
||||
|
||||
await Preferences.set({ key: 'customServerUrl', value: trimmedURL })
|
||||
await apiClient.init(true)
|
||||
refetchResource()
|
||||
setStatus('success')
|
||||
|
||||
setTimeout(() => {
|
||||
Navigate('/login')
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
const handleURLChange = e => {
|
||||
setServerURL(e.target.value)
|
||||
if (status !== 'idle') {
|
||||
setStatus('idle')
|
||||
setErrorMessage('')
|
||||
}
|
||||
}
|
||||
|
||||
const isTesting = status === 'testing'
|
||||
|
||||
return (
|
||||
<Container component='main' maxWidth='xs'>
|
||||
<Box
|
||||
@@ -38,7 +138,6 @@ const LoginSettings = () => {
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
@@ -51,13 +150,7 @@ const LoginSettings = () => {
|
||||
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span
|
||||
style={{
|
||||
color: '#06b6d4',
|
||||
}}
|
||||
>
|
||||
tick
|
||||
</span>
|
||||
<span style={{ color: '#06b6d4' }}>tick</span>
|
||||
</Typography>
|
||||
|
||||
<Typography level='body2' alignSelf={'start'} mt={4}>
|
||||
@@ -71,9 +164,22 @@ const LoginSettings = () => {
|
||||
name='serverURL'
|
||||
autoFocus
|
||||
value={serverURL}
|
||||
onChange={e => {
|
||||
setServerURL(e.target.value)
|
||||
}}
|
||||
onChange={handleURLChange}
|
||||
disabled={isTesting}
|
||||
color={
|
||||
status === 'success'
|
||||
? 'success'
|
||||
: status === 'error'
|
||||
? 'danger'
|
||||
: 'neutral'
|
||||
}
|
||||
endDecorator={
|
||||
status === 'success' ? (
|
||||
<CheckCircleOutlineIcon color='success' fontSize='small' />
|
||||
) : status === 'error' ? (
|
||||
<ErrorOutlineIcon color='error' fontSize='small' />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Typography mt={1} level='body-xs'>
|
||||
@@ -81,72 +187,68 @@ const LoginSettings = () => {
|
||||
own self-hosted Donetick server.
|
||||
</Typography>
|
||||
<Typography mt={1} level='body-xs'>
|
||||
Please ensure to include the protocol (http:// or https://) and the
|
||||
port number if necessary (default Donetick port is 2021).
|
||||
Include the protocol (http:// or https://) and port if necessary
|
||||
(default Donetick port is 2021).
|
||||
</Typography>
|
||||
|
||||
{status === 'error' && (
|
||||
<Alert
|
||||
color='danger'
|
||||
variant='soft'
|
||||
startDecorator={<ErrorOutlineIcon />}
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<Alert
|
||||
color='success'
|
||||
variant='soft'
|
||||
startDecorator={<CheckCircleOutlineIcon />}
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
Connected! Redirecting to login...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'testing' && (
|
||||
<Alert
|
||||
color='neutral'
|
||||
variant='soft'
|
||||
startDecorator={<WifiIcon />}
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
Testing connection to server...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='solid'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (serverURL === '') {
|
||||
Preferences.set({
|
||||
key: 'customServerUrl',
|
||||
value: API_URL,
|
||||
}).then(() => {
|
||||
Navigate('/login')
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!isValidServerURL()) {
|
||||
showError({
|
||||
title: 'Invalid Server URL',
|
||||
message:
|
||||
'Please enter a valid server URL with protocol (http:// or https://)',
|
||||
})
|
||||
return
|
||||
}
|
||||
Preferences.set({
|
||||
key: 'customServerUrl',
|
||||
value: serverURL,
|
||||
}).then(async () => {
|
||||
// apiClient.customServerURL = serverURL + '/api/v1's
|
||||
// Force re-initialization to reload from Preferences
|
||||
await apiClient.init(true)
|
||||
// refetch resource queries to update the API URL
|
||||
refetchResource()
|
||||
Navigate('/login')
|
||||
})
|
||||
}}
|
||||
disabled={isTesting || status === 'success'}
|
||||
sx={{ width: '100%', mt: 2, mb: 2, borderRadius: '8px' }}
|
||||
onClick={handleSave}
|
||||
startDecorator={
|
||||
isTesting ? <CircularProgress size='sm' /> : undefined
|
||||
}
|
||||
>
|
||||
Save
|
||||
{isTesting ? 'Testing...' : 'Save & Connect'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
sx={{
|
||||
width: '100%',
|
||||
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
Preferences.set({ key: 'customServerUrl', value: API_URL }).then(
|
||||
() => {
|
||||
refetchResource()
|
||||
Navigate('/login')
|
||||
},
|
||||
)
|
||||
disabled={isTesting}
|
||||
sx={{ width: '100%', mb: 2, borderRadius: '8px' }}
|
||||
onClick={async () => {
|
||||
await Preferences.set({ key: 'customServerUrl', value: API_URL })
|
||||
await apiClient.init(true)
|
||||
refetchResource()
|
||||
Navigate('/login')
|
||||
}}
|
||||
>
|
||||
Cancel and Reset
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Add, ArrowDropDown, HorizontalRule, Save } from '@mui/icons-material'
|
||||
import {
|
||||
Add,
|
||||
ArrowDropDown,
|
||||
AttachFile,
|
||||
Delete,
|
||||
HorizontalRule,
|
||||
Save,
|
||||
UploadFile,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -43,8 +51,13 @@ import {
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import { GetAllCircleMembers, GetThings } from '../../utils/Fetcher'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import {
|
||||
DeleteChoreAttachment,
|
||||
GetAllCircleMembers,
|
||||
GetThings,
|
||||
UploadChoreAttachment,
|
||||
} from '../../utils/Fetcher'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
||||
import Priorities from '../../utils/Priorities.jsx'
|
||||
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||
@@ -53,6 +66,7 @@ import LoadingComponent from '../components/Loading.jsx'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
import SubTasks from '../components/SubTask.jsx'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import AttachmentViewerModal from '../Modals/Inputs/AttachmentViewerModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||
import { useProjects } from '../Projects/ProjectQueries'
|
||||
@@ -83,7 +97,8 @@ const ChoreEdit = () => {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||
const [assignees, setAssignees] = useState([])
|
||||
const [anyone, setAnyone] = useState(false)
|
||||
const [assignableTo, setAssignableTo] = useState([])
|
||||
const [performers, setPerformers] = useState([])
|
||||
const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2])
|
||||
const [dueDate, setDueDate] = useState(null)
|
||||
@@ -116,7 +131,13 @@ const ChoreEdit = () => {
|
||||
const [createdBy, setCreatedBy] = useState(0)
|
||||
const [errors, setErrors] = useState({})
|
||||
const [attemptToSave, setAttemptToSave] = useState(false)
|
||||
const [draftId] = useState(() => crypto.randomUUID())
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [isUploadingAttachment, setIsUploadingAttachment] = useState(false)
|
||||
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
|
||||
const [attachmentViewerConfig, setAttachmentViewerConfig] = useState({
|
||||
isOpen: false,
|
||||
})
|
||||
const [showSavePrivacyDefault, setShowSavePrivacyDefault] = useState(false)
|
||||
const [privacySaved, setPrivacySaved] = useState(false)
|
||||
const [showSaveNotificationDefault, setShowSaveNotificationDefault] =
|
||||
@@ -158,6 +179,7 @@ const ChoreEdit = () => {
|
||||
|
||||
const Navigate = useNavigate()
|
||||
|
||||
const assignees = anyone ? performers : assignableTo
|
||||
const HandleValidateChore = () => {
|
||||
const errors = {}
|
||||
|
||||
@@ -330,6 +352,7 @@ const ChoreEdit = () => {
|
||||
if (searchParams.get('clone') === 'true') {
|
||||
newChoreId = null
|
||||
}
|
||||
const assignees = anyone ? [] : assignableTo
|
||||
const chore = {
|
||||
id: Number(newChoreId),
|
||||
name: name,
|
||||
@@ -359,6 +382,7 @@ const ChoreEdit = () => {
|
||||
deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset,
|
||||
priority: priority,
|
||||
projectId: projectId === 'default' ? null : projectId,
|
||||
draftId: newChoreId > 0 ? undefined : draftId,
|
||||
}
|
||||
let SaveFunction = createChoreMutation.mutateAsync
|
||||
if (newChoreId > 0) {
|
||||
@@ -419,15 +443,29 @@ const ChoreEdit = () => {
|
||||
setIsNotificable(JSON.parse(defaultNotificationSetting))
|
||||
}
|
||||
|
||||
const defaultAnyoneSetting = localStorage.getItem('defaultAnyoneSetting')
|
||||
if (defaultAnyoneSetting != null) {
|
||||
const savedAnyone = JSON.parse(defaultAnyoneSetting)
|
||||
setAnyone(savedAnyone)
|
||||
}
|
||||
|
||||
const defaultAssigneeSetting = localStorage.getItem(
|
||||
'defaultAssigneeSetting',
|
||||
)
|
||||
if (defaultAssigneeSetting !== null) {
|
||||
const savedAssignees = JSON.parse(defaultAssigneeSetting)
|
||||
setAssignees(savedAssignees)
|
||||
setAssignableTo(savedAssignees)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const anyoneSetting = localStorage.getItem('defaultAnyoneSetting')
|
||||
const anyoneDirty = anyoneSetting !== JSON.stringify(anyone)
|
||||
const assigneeSetting = localStorage.getItem('defaultAssigneeSetting')
|
||||
const assigneeDirty = assigneeSetting !== JSON.stringify(assignableTo)
|
||||
const dirty = anyoneDirty || (!anyone && assigneeDirty)
|
||||
setShowSaveAssigneeDefault(dirty)
|
||||
}, [anyone, assignableTo])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
@@ -477,7 +515,8 @@ const ChoreEdit = () => {
|
||||
setChore(data.res)
|
||||
setName(data.res.name ? data.res.name : '')
|
||||
setDescription(data.res.description ? data.res.description : '')
|
||||
setAssignees(data.res.assignees ? data.res.assignees : [])
|
||||
setAssignableTo(data.res.assignees ? data.res.assignees : [])
|
||||
setAnyone((data.res.assignees?.length || 0) === 0)
|
||||
setAssignedTo(data.res.assignedTo)
|
||||
setFrequencyType(data.res.frequencyType ? data.res.frequencyType : 'once')
|
||||
|
||||
@@ -558,6 +597,7 @@ const ChoreEdit = () => {
|
||||
|
||||
setCreatedBy(data.res.createdBy)
|
||||
setUpdatedBy(data.res.updatedBy)
|
||||
setAttachments(data.res.attachments || [])
|
||||
}
|
||||
}, [choreData, isChoreLoading, searchParams])
|
||||
|
||||
@@ -591,13 +631,15 @@ const ChoreEdit = () => {
|
||||
if (assignees.length === 0) {
|
||||
setAssignStrategy('no_assignee')
|
||||
setAssignedTo(null)
|
||||
} else if (assignees.length === 1) {
|
||||
setAssignedTo(assignees[0].userId)
|
||||
} else {
|
||||
if (!assignees.some(a => a.userId === assignedTo)) {
|
||||
setAssignedTo(assignees[0].userId)
|
||||
}
|
||||
if (assignStrategy === 'no_assignee') {
|
||||
setAssignStrategy(ASSIGN_STRATEGIES[2]) // default to least_completed
|
||||
}
|
||||
}
|
||||
}, [assignees, assignStrategy])
|
||||
}, [assignStrategy, assignedTo, assignees])
|
||||
|
||||
// useEffect(() => {
|
||||
// if (performers.length > 0 && assignees.length === 0 && userProfile) {
|
||||
@@ -614,7 +656,7 @@ const ChoreEdit = () => {
|
||||
if (attemptToSave) {
|
||||
HandleValidateChore()
|
||||
}
|
||||
}, [assignees, name, frequencyMetadata, attemptToSave, dueDate])
|
||||
}, [assignableTo, name, frequencyMetadata, attemptToSave, dueDate])
|
||||
|
||||
const handleDelete = () => {
|
||||
setConfirmModelConfig({
|
||||
@@ -921,6 +963,175 @@ const ChoreEdit = () => {
|
||||
/>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box mt={3}>
|
||||
<Typography level='h4'>Attachments</Typography>
|
||||
<Typography level='body-md'>Files attached to this task</Typography>
|
||||
<Card variant='outlined' sx={{ mt: 2, p: 1.5 }}>
|
||||
{attachments.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 1.5 }}>
|
||||
{attachments.map((att, idx) => (
|
||||
<Box
|
||||
key={att.file_path || idx}
|
||||
onClick={() => {
|
||||
const url = resolvePhotoURL(att.sign || att.file_path)
|
||||
const ext = (att.file_name || '')
|
||||
.split('.')
|
||||
.pop()
|
||||
.toLowerCase()
|
||||
const isImage = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'svg',
|
||||
].includes(ext)
|
||||
if (isImage) {
|
||||
setAttachmentViewerConfig({
|
||||
isOpen: true,
|
||||
url,
|
||||
fileName: att.file_name,
|
||||
onClose: () =>
|
||||
setAttachmentViewerConfig({ isOpen: false }),
|
||||
})
|
||||
} else {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = att.file_name || 'attachment'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 1,
|
||||
borderRadius: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: 'neutral.softHoverBg' },
|
||||
}}
|
||||
>
|
||||
<AttachFile sx={{ fontSize: 18, color: 'neutral.500' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ flex: 1, wordBreak: 'break-all' }}
|
||||
>
|
||||
{att.file_name}
|
||||
</Typography>
|
||||
{att.size_bytes && (
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
{(att.size_bytes / 1024).toFixed(1)} KB
|
||||
</Typography>
|
||||
)}
|
||||
{choreId && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='danger'
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
DeleteChoreAttachment(choreId, att.file_path)
|
||||
.then(() => {
|
||||
setAttachments(prev =>
|
||||
prev.filter(a => a.file_path !== att.file_path),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
showError({
|
||||
title: 'Delete Failed',
|
||||
message: 'Failed to delete attachment.',
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Delete sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
{!choreId && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='danger'
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setAttachments(prev =>
|
||||
prev.filter((_, i) => i !== idx),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Delete sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
component='label'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={
|
||||
isUploadingAttachment ? null : <UploadFile />
|
||||
}
|
||||
loading={isUploadingAttachment}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
Upload File
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
onChange={async e => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
setIsUploadingAttachment(true)
|
||||
try {
|
||||
const response = choreId
|
||||
? await UploadChoreAttachment(file, 'chore_attachment', {
|
||||
entityId: choreId,
|
||||
})
|
||||
: await UploadChoreAttachment(
|
||||
file,
|
||||
'chore_attachment_draft',
|
||||
{ draftId },
|
||||
)
|
||||
if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
setAttachments(prev => [
|
||||
...prev,
|
||||
{
|
||||
file_path: data.path,
|
||||
file_name: data.file_name,
|
||||
size_bytes: data.size_bytes,
|
||||
sign: data.sign,
|
||||
},
|
||||
])
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
} finally {
|
||||
setIsUploadingAttachment(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Section 2: Assignment & Responsibility */}
|
||||
@@ -941,9 +1152,9 @@ const ChoreEdit = () => {
|
||||
|
||||
<ListItem key={'anyone'}>
|
||||
<Checkbox
|
||||
checked={assignees.length === 0}
|
||||
checked={anyone}
|
||||
onClick={() => {
|
||||
setAssignees([])
|
||||
setAnyone(!anyone)
|
||||
setIsPrivate(false)
|
||||
}}
|
||||
overlay
|
||||
@@ -956,19 +1167,25 @@ const ChoreEdit = () => {
|
||||
{performers?.map((item, index) => (
|
||||
<ListItem key={item.id}>
|
||||
<Checkbox
|
||||
checked={
|
||||
assignees.find(a => a.userId == item.userId) != null
|
||||
}
|
||||
checked={assignableTo.some(a => a.userId == item.userId)}
|
||||
disabled={anyone}
|
||||
onClick={() => {
|
||||
if (anyone) {
|
||||
setAnyone(false)
|
||||
setAssignableTo([{ userId: item.userId }])
|
||||
return
|
||||
}
|
||||
const assignees = assignableTo
|
||||
const setAssignees = setAssignableTo
|
||||
if (assignees.some(a => a.userId === item.userId)) {
|
||||
const newAssignees = assignees.filter(
|
||||
a => a.userId !== item.userId,
|
||||
)
|
||||
setAnyone(newAssignees.length === 0)
|
||||
setAssignees(newAssignees)
|
||||
} else {
|
||||
setAssignees([...assignees, { userId: item.userId }])
|
||||
}
|
||||
setShowSaveAssigneeDefault(true)
|
||||
}}
|
||||
overlay
|
||||
disableIcon
|
||||
@@ -998,9 +1215,13 @@ const ChoreEdit = () => {
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
localStorage.setItem(
|
||||
'defaultAnyoneSetting',
|
||||
JSON.stringify(anyone),
|
||||
)
|
||||
localStorage.setItem(
|
||||
'defaultAssigneeSetting',
|
||||
JSON.stringify(assignees),
|
||||
JSON.stringify(assignableTo),
|
||||
)
|
||||
setShowSaveAssigneeDefault(false)
|
||||
}}
|
||||
@@ -1026,17 +1247,12 @@ const ChoreEdit = () => {
|
||||
}
|
||||
disabled={assignees.length === 0}
|
||||
value={assignedTo > -1 ? assignedTo : null}
|
||||
onChange={(_, selectedUserId) => setAssignedTo(selectedUserId)}
|
||||
>
|
||||
{performers
|
||||
?.filter(p => assignees.find(a => a.userId == p.userId))
|
||||
?.filter(p => assignees.some(a => a.userId == p.userId))
|
||||
.map((item, index) => (
|
||||
<Option
|
||||
value={item.userId}
|
||||
key={item.displayName}
|
||||
onClick={() => {
|
||||
setAssignedTo(item.userId)
|
||||
}}
|
||||
>
|
||||
<Option value={item.userId} key={item.displayName}>
|
||||
{item.displayName}
|
||||
</Option>
|
||||
))}
|
||||
@@ -1707,6 +1923,7 @@ const ChoreEdit = () => {
|
||||
)}
|
||||
</Button>
|
||||
</Sheet>
|
||||
<AttachmentViewerModal config={attachmentViewerConfig} />
|
||||
<ConfirmationModal config={confirmModelConfig} />
|
||||
{addLabelModalOpen && (
|
||||
<LabelModal
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Archive,
|
||||
AttachFile,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
Checklist,
|
||||
@@ -78,6 +79,7 @@ import {
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import LoadingComponent from '../components/Loading.jsx'
|
||||
@@ -86,6 +88,7 @@ import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
import SubTasks from '../components/SubTask.jsx'
|
||||
import TimePassedCard from './TimePassedCard.jsx'
|
||||
import TimerSplitButton from './TimerSplitButton.jsx'
|
||||
import { refreshSignedUrlsInHtml } from '../../utils/Helpers.jsx'
|
||||
|
||||
const isNetworkError = err =>
|
||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||
@@ -124,6 +127,7 @@ const ChoreView = () => {
|
||||
const [chorePriority, setChorePriority] = useState(null)
|
||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
|
||||
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
|
||||
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -132,6 +136,7 @@ const ChoreView = () => {
|
||||
const { data: choreData, isLoading: isChoreLoading } =
|
||||
useChoreDetails(choreId)
|
||||
const { data: choreHistoryData } = useChoreHistory(choreId)
|
||||
|
||||
const { data: pendingCmds } = usePendingCommands(choreId)
|
||||
|
||||
const choreHistory = choreHistoryData?.res || []
|
||||
@@ -158,8 +163,8 @@ const ChoreView = () => {
|
||||
document.title = 'Donetick: ' + choreData.res.name
|
||||
|
||||
setPerformers(circleMembersData.res)
|
||||
const auto_complete = searchParams.get('auto_complete')
|
||||
if (auto_complete === 'true') {
|
||||
if (searchParams.get('auto_complete') === 'true') {
|
||||
navigate({ search: '' }, { replace: true })
|
||||
handleTaskCompletion()
|
||||
}
|
||||
}, [choreData, circleMembersData])
|
||||
@@ -651,16 +656,14 @@ const ChoreView = () => {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
mb: 1,
|
||||
flexWrap: 'wrap',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{chore?.labelsV2?.map((label, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
ml: index === 0 ? 0 : 0.5,
|
||||
top: 2,
|
||||
zIndex: 1,
|
||||
backgroundColor: label?.color,
|
||||
color: getTextColorFromBackgroundColor(label?.color),
|
||||
}}
|
||||
@@ -668,6 +671,20 @@ const ChoreView = () => {
|
||||
{label?.name}
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
{chore?.attachments?.length > 0 && (
|
||||
<Chip
|
||||
startDecorator={<AttachFile />}
|
||||
size='md'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
onClick={() => setAttachmentBrowserOpen(true)}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
{chore.attachments.length}{' '}
|
||||
{chore.attachments.length === 1 ? 'attachment' : 'attachments'}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -918,7 +935,7 @@ const ChoreView = () => {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: raw }}
|
||||
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
@@ -986,7 +1003,7 @@ const ChoreView = () => {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: raw }}
|
||||
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
@@ -1324,6 +1341,11 @@ const ChoreView = () => {
|
||||
<ConfirmationModal config={confirmModelConfig} />
|
||||
<ConfirmationModal config={timerActionConfig} />
|
||||
<NoteViewerModal config={noteViewerConfig} />
|
||||
<AttachmentBrowserModal
|
||||
choreId={choreId}
|
||||
isOpen={attachmentBrowserOpen}
|
||||
onClose={() => setAttachmentBrowserOpen(false)}
|
||||
/>
|
||||
</Card>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -108,7 +108,7 @@ const generateSchedulePreview = (metadata, formatTimeFn) => {
|
||||
return `Every ${dayNames} at ${timeStr}`
|
||||
}
|
||||
|
||||
const RepeatOnSections = ({
|
||||
export const RepeatOnSections = ({
|
||||
frequencyType,
|
||||
frequency,
|
||||
onFrequencyUpdate,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import {
|
||||
Archive,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
Close,
|
||||
Delete,
|
||||
SelectAll,
|
||||
Unarchive,
|
||||
ViewAgenda,
|
||||
ViewModule,
|
||||
Archive,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
Close,
|
||||
Delete,
|
||||
Label,
|
||||
Person,
|
||||
PriorityHigh,
|
||||
SelectAll,
|
||||
Unarchive,
|
||||
ViewAgenda,
|
||||
ViewModule,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
@@ -22,15 +25,18 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
import { useUnArchiveChore } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
@@ -107,6 +113,95 @@ const ArchivedTasks = () => {
|
||||
|
||||
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
|
||||
|
||||
// Unique labels present across all archived chores
|
||||
const availableLabels = useMemo(() => {
|
||||
const seen = {}
|
||||
archivedChores.forEach(c => {
|
||||
c.labelsV2?.forEach(l => { seen[l.id] = l })
|
||||
})
|
||||
return Object.values(seen)
|
||||
}, [archivedChores])
|
||||
|
||||
const filterDefs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'assignee',
|
||||
label: 'Assignee',
|
||||
type: 'multi-select',
|
||||
icon: <Person />,
|
||||
options: performers.map(p => ({
|
||||
value: p.userId,
|
||||
label: p.displayName,
|
||||
avatar: p.image,
|
||||
})),
|
||||
filterFn: (item, values) => values.includes(item.assignedTo),
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
label: 'Priority',
|
||||
type: 'multi-select',
|
||||
icon: <PriorityHigh />,
|
||||
options: Priorities.map(p => ({
|
||||
value: p.value,
|
||||
label: p.name,
|
||||
color: p.color || 'neutral',
|
||||
icon: p.icon,
|
||||
})),
|
||||
filterFn: (item, values) => values.includes(item.priority ?? 0),
|
||||
},
|
||||
...(availableLabels.length > 0
|
||||
? [
|
||||
{
|
||||
id: 'label',
|
||||
label: 'Labels',
|
||||
type: 'multi-select',
|
||||
icon: <Label />,
|
||||
options: availableLabels.map(l => ({
|
||||
value: l.id,
|
||||
label: l.name,
|
||||
icon: (
|
||||
<Box
|
||||
component='span'
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: l.color || '#90a4ae',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
filterFn: (item, values) =>
|
||||
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'archivedAt',
|
||||
label: 'Archived Date',
|
||||
type: 'date-range',
|
||||
icon: <Archive />,
|
||||
filterFn: (item, value) => {
|
||||
const date = new Date(item.updatedAt)
|
||||
if (value.from && date < new Date(value.from)) return false
|
||||
if (value.to && date > new Date(value.to)) return false
|
||||
return true
|
||||
},
|
||||
},
|
||||
],
|
||||
[performers, availableLabels],
|
||||
)
|
||||
|
||||
const {
|
||||
filteredData: finalChores,
|
||||
activeFilters,
|
||||
setFilter,
|
||||
clearAll,
|
||||
hasActiveFilters,
|
||||
} = useFilter(filteredChores, filterDefs)
|
||||
|
||||
useEffect(() => {
|
||||
const loadArchivedChores = async () => {
|
||||
if (!membersLoading && userProfile) {
|
||||
@@ -335,11 +430,8 @@ const ArchivedTasks = () => {
|
||||
}
|
||||
|
||||
const selectAllVisibleChores = () => {
|
||||
const visibleChores =
|
||||
searchTerm?.length > 0 ? filteredChores : archivedChores
|
||||
if (visibleChores.length > 0) {
|
||||
const allIds = new Set(visibleChores.map(chore => chore.id))
|
||||
setSelectedChores(allIds)
|
||||
if (finalChores.length > 0) {
|
||||
setSelectedChores(new Set(finalChores.map(c => c.id)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,6 +765,15 @@ const ArchivedTasks = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FilterBar
|
||||
filterDefs={filterDefs}
|
||||
activeFilters={activeFilters}
|
||||
onSetFilter={setFilter}
|
||||
onClearAll={clearAll}
|
||||
resultCount={finalChores.length}
|
||||
totalCount={filteredChores.length}
|
||||
/>
|
||||
|
||||
{/* Multi-select Toolbar */}
|
||||
{isMultiSelectMode && (
|
||||
<Box
|
||||
@@ -745,7 +846,7 @@ const ArchivedTasks = () => {
|
||||
variant='outlined'
|
||||
onClick={selectAllVisibleChores}
|
||||
startDecorator={<SelectAll />}
|
||||
disabled={selectedChores.size === filteredChores.length}
|
||||
disabled={selectedChores.size === finalChores.length}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
@@ -876,7 +977,7 @@ const ArchivedTasks = () => {
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{filteredChores.length === 0 ? (
|
||||
{finalChores.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -886,42 +987,43 @@ const ArchivedTasks = () => {
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<Archive
|
||||
sx={{
|
||||
fontSize: '4rem',
|
||||
mb: 1,
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
/>
|
||||
<Archive sx={{ fontSize: '4rem', mb: 1, color: 'text.tertiary' }} />
|
||||
<Typography level='title-md' gutterBottom>
|
||||
{searchTerm ? 'No archived tasks found' : 'No archived tasks'}
|
||||
{searchTerm || hasActiveFilters
|
||||
? 'No archived tasks found'
|
||||
: 'No archived tasks'}
|
||||
</Typography>
|
||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||
{searchTerm
|
||||
? 'Try adjusting your search terms'
|
||||
{searchTerm || hasActiveFilters
|
||||
? 'Try adjusting your search or filters'
|
||||
: 'Archived tasks will appear here when you archive them from the main task list'}
|
||||
</Typography>
|
||||
{searchTerm && (
|
||||
<Button
|
||||
onClick={handleSearchClose}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
>
|
||||
Clear search
|
||||
</Button>
|
||||
{(searchTerm || hasActiveFilters) && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{searchTerm && (
|
||||
<Button onClick={handleSearchClose} variant='outlined' color='neutral'>
|
||||
Clear search
|
||||
</Button>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<Button onClick={clearAll} variant='outlined' color='neutral'>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||
{filteredChores.length} archived task
|
||||
{filteredChores.length !== 1 ? 's' : ''}
|
||||
{finalChores.length} archived task
|
||||
{finalChores.length !== 1 ? 's' : ''}
|
||||
{searchTerm && ` matching "${searchTerm}"`}
|
||||
</Typography>
|
||||
|
||||
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
|
||||
<ChoreListView
|
||||
chores={filteredChores}
|
||||
chores={finalChores}
|
||||
// viewOnly={true}
|
||||
showActions={false}
|
||||
viewMode={viewMode}
|
||||
|
||||
@@ -190,7 +190,11 @@ const scheduleChoreNotification = async (
|
||||
for (let i = 0; i < chores.length; i++) {
|
||||
const chore = chores[i]
|
||||
try {
|
||||
if (chore.notification === false || chore.nextDueDate === null) {
|
||||
if (
|
||||
chore.notification === false ||
|
||||
chore.nextDueDate === null ||
|
||||
chore.isActive === false
|
||||
) {
|
||||
continue
|
||||
}
|
||||
scheduleNotificationFromTemplate(
|
||||
|
||||
@@ -2,18 +2,12 @@ import {
|
||||
Add,
|
||||
Bolt,
|
||||
CalendarMonth,
|
||||
CancelRounded,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
EditCalendar,
|
||||
ExpandCircleDown,
|
||||
Grain,
|
||||
PriorityHigh,
|
||||
Sort,
|
||||
Style,
|
||||
ViewAgenda,
|
||||
ViewModule,
|
||||
} from '@mui/icons-material'
|
||||
import Logo from '../../Logo'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
@@ -24,9 +18,6 @@ import {
|
||||
Container,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Fuse from 'fuse.js'
|
||||
@@ -43,6 +34,7 @@ import IconButtonWithMenu from './IconButtonWithMenu'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import {
|
||||
@@ -55,15 +47,13 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
|
||||
import TaskInput from '../components/AddTaskModal'
|
||||
import CalendarDual from '../components/CalendarDual'
|
||||
import CalendarMonthly from '../components/CalendarMonthly.jsx'
|
||||
import ProjectSelector from '../components/ProjectSelector'
|
||||
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
|
||||
import { useProjects } from '../Projects/ProjectQueries.js'
|
||||
import ChoreListView from './ChoreListView.jsx'
|
||||
import ChoreToolbar from './components/ChoreToolbarPrototype'
|
||||
import ChoreModals from './components/ChoreModals'
|
||||
import FilterSection from './components/FilterSection'
|
||||
import MultiSelectToolbar from './components/MultiSelectToolbar'
|
||||
import MyChoreHeader from './components/MyChoreHeader'
|
||||
import SearchBar from './components/SearchBar'
|
||||
import { useChoreActions } from './hooks/useChoreActions'
|
||||
import { useChoreFilters } from './hooks/useChoreFilters'
|
||||
import { useChoreModals } from './hooks/useChoreModals'
|
||||
@@ -78,7 +68,6 @@ import {
|
||||
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
||||
import Sidepanel from './Sidepanel'
|
||||
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
|
||||
import SortAndGrouping from './SortAndGrouping'
|
||||
|
||||
const MyChores = () => {
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
@@ -107,7 +96,6 @@ const MyChores = () => {
|
||||
const [chores, setChores] = useState([])
|
||||
const [filteredChores, setFilteredChores] = useState([])
|
||||
const [choreSections, setChoreSections] = useState([])
|
||||
const [showSearchFilter, setShowSearchFilter] = useState(false)
|
||||
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false)
|
||||
const [taskInputFocus, setTaskInputFocus] = useState(0)
|
||||
const searchInputRef = useRef(null)
|
||||
@@ -135,15 +123,12 @@ const MyChores = () => {
|
||||
|
||||
const {
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
selectedChoreFilter,
|
||||
projectFilteredChores,
|
||||
searchFilteredChores,
|
||||
nonProjectFilteredChores,
|
||||
setSearchTerm,
|
||||
setSearchFilter,
|
||||
setSelectedChoreFilterWithCache,
|
||||
clearFilters,
|
||||
} = useChoreFilters({
|
||||
chores,
|
||||
selectedProject,
|
||||
@@ -193,6 +178,102 @@ const MyChores = () => {
|
||||
useState(false)
|
||||
const [editingFilter, setEditingFilter] = useState(null)
|
||||
|
||||
const quickFilterDefs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Due Date',
|
||||
type: 'single-select',
|
||||
icon: <CalendarMonth />,
|
||||
options: [
|
||||
{ value: 'Overdue', label: 'Overdue', color: 'danger' },
|
||||
{ value: 'Due today', label: 'Due Today', color: 'warning' },
|
||||
{ value: 'Due in week', label: 'Due This Week' },
|
||||
{ value: 'Due Later', label: 'Due Later' },
|
||||
{ value: 'No Due Date', label: 'No Due Date' },
|
||||
{ value: 'Pending Approval', label: 'Pending Approval' },
|
||||
],
|
||||
filterFn: (item, value) => {
|
||||
const now = new Date()
|
||||
const d = item.nextDueDate ? new Date(item.nextDueDate) : null
|
||||
switch (value) {
|
||||
case 'Overdue':
|
||||
return d !== null && d < now
|
||||
case 'Due today':
|
||||
return d !== null && d.toDateString() === now.toDateString()
|
||||
case 'Due in week':
|
||||
return (
|
||||
d !== null &&
|
||||
d < new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) &&
|
||||
d > now
|
||||
)
|
||||
case 'Due Later':
|
||||
return (
|
||||
d !== null &&
|
||||
d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
|
||||
)
|
||||
case 'No Due Date':
|
||||
return item.nextDueDate === null
|
||||
case 'Pending Approval':
|
||||
return item.status === 3
|
||||
default:
|
||||
return true
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
label: 'Priority',
|
||||
type: 'multi-select',
|
||||
icon: <PriorityHigh />,
|
||||
options: Priorities.map(p => ({
|
||||
value: p.value,
|
||||
label: p.name,
|
||||
color: p.color || 'neutral',
|
||||
icon: p.icon,
|
||||
})),
|
||||
filterFn: (item, values) => values.includes(item.priority ?? 0),
|
||||
},
|
||||
...(userLabels?.length > 0
|
||||
? [
|
||||
{
|
||||
id: 'label',
|
||||
label: 'Labels',
|
||||
type: 'multi-select',
|
||||
icon: <Style />,
|
||||
options: userLabels.map(l => ({
|
||||
value: l.id,
|
||||
label: l.name,
|
||||
icon: (
|
||||
<Box
|
||||
component='span'
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: l.color || '#90a4ae',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
filterFn: (item, values) =>
|
||||
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[userLabels],
|
||||
)
|
||||
|
||||
const {
|
||||
filteredData: quickFilteredChores,
|
||||
setFilter: setQuickFilter,
|
||||
clearAll: clearQuickFilters,
|
||||
hasActiveFilters: hasQuickFilters,
|
||||
} = useFilter(projectFilteredChores, quickFilterDefs)
|
||||
|
||||
const processedChores = useMemo(() => {
|
||||
if (!choresData?.res) {
|
||||
return []
|
||||
@@ -222,9 +303,9 @@ const MyChores = () => {
|
||||
if (tempFilter || activeFilterId) {
|
||||
// Advanced/custom filter active
|
||||
choresToGroup = customFilteredChores
|
||||
} else if (searchFilter !== 'All') {
|
||||
// Quick filter active (Overdue, Due today, Label, Priority, etc.)
|
||||
choresToGroup = filteredChores
|
||||
} else if (hasQuickFilters) {
|
||||
// Quick filter active (Due date, Priority, Labels)
|
||||
choresToGroup = quickFilteredChores
|
||||
} else if (!selectedProject || selectedProject.id === 'default') {
|
||||
// No project selected or default project: only show tasks without a projectId
|
||||
choresToGroup = chores.filter(chore => !chore.projectId)
|
||||
@@ -244,11 +325,11 @@ const MyChores = () => {
|
||||
return sections
|
||||
}, [
|
||||
chores,
|
||||
filteredChores,
|
||||
quickFilteredChores,
|
||||
customFilteredChores,
|
||||
tempFilter,
|
||||
activeFilterId,
|
||||
searchFilter,
|
||||
hasQuickFilters,
|
||||
selectedChoreSection,
|
||||
selectedChoreFilter,
|
||||
selectedProject,
|
||||
@@ -398,7 +479,7 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
// Handle legacy filter parameter (e.g., filter=unplanned)
|
||||
if (oldFilter && searchFilter === 'All' && !activeFilterId) {
|
||||
if (oldFilter && !hasQuickFilters && !activeFilterId) {
|
||||
const filterMap = {
|
||||
unplanned: 'No Due Date',
|
||||
overdue: 'Overdue',
|
||||
@@ -409,12 +490,8 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
const filterName = filterMap[oldFilter.toLowerCase()]
|
||||
if (filterName && FILTERS[filterName]) {
|
||||
const filtered = FILTERS[filterName](
|
||||
selectedProject ? projectFilteredChores : chores,
|
||||
)
|
||||
setFilteredChores(filtered)
|
||||
setSearchFilter(filterName)
|
||||
if (filterName) {
|
||||
setQuickFilter('status', filterName)
|
||||
setViewMode('default')
|
||||
setSelectedCalendarDate(null)
|
||||
}
|
||||
@@ -422,7 +499,7 @@ const MyChores = () => {
|
||||
}, [
|
||||
searchParams,
|
||||
chores,
|
||||
searchFilter,
|
||||
hasQuickFilters,
|
||||
activeFilterId,
|
||||
savedFilters,
|
||||
applyCustomFilter,
|
||||
@@ -430,8 +507,7 @@ const MyChores = () => {
|
||||
clearActiveFilter,
|
||||
selectedProject,
|
||||
projectFilteredChores,
|
||||
setSearchFilter,
|
||||
setFilteredChores,
|
||||
setQuickFilter,
|
||||
setViewMode,
|
||||
setSelectedCalendarDate,
|
||||
])
|
||||
@@ -496,13 +572,47 @@ const MyChores = () => {
|
||||
clearSelection,
|
||||
})
|
||||
|
||||
const getFilteredChores = useMemo(() => {
|
||||
if (activeFilterId || tempFilter) {
|
||||
return customFilteredChores
|
||||
}
|
||||
|
||||
const baseChores = hasQuickFilters
|
||||
? quickFilteredChores
|
||||
: projectFilteredChores
|
||||
|
||||
if (searchTerm?.length > 0) {
|
||||
const searchableChores = baseChores.map(c => ({
|
||||
...c,
|
||||
raw_label: c.labelsV2?.map(l => l.name).join(' '),
|
||||
}))
|
||||
const fuse = new Fuse(searchableChores, {
|
||||
keys: ['name', 'raw_label'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
})
|
||||
return fuse.search(searchTerm).map(result => result.item)
|
||||
}
|
||||
|
||||
return baseChores
|
||||
}, [
|
||||
activeFilterId,
|
||||
tempFilter,
|
||||
customFilteredChores,
|
||||
hasQuickFilters,
|
||||
quickFilteredChores,
|
||||
projectFilteredChores,
|
||||
searchTerm,
|
||||
])
|
||||
|
||||
const { showKeyboardShortcuts } = useKeyboardShortcuts({
|
||||
isMultiSelectMode,
|
||||
selectedChores,
|
||||
addTaskModalOpen,
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
filteredChores,
|
||||
searchFilter: hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
|
||||
filteredChores: getFilteredChores,
|
||||
choreSections,
|
||||
openChoreSections,
|
||||
handlers: {
|
||||
@@ -553,25 +663,10 @@ const MyChores = () => {
|
||||
|
||||
const handleLabelFiltering = chipClicked => {
|
||||
clearActiveFilter()
|
||||
|
||||
const baseChores = selectedProject ? projectFilteredChores : chores
|
||||
|
||||
if (chipClicked.label) {
|
||||
const label = chipClicked.label
|
||||
const labelFiltered = baseChores.filter(chore =>
|
||||
chore.labelsV2.some(
|
||||
l => l.id === label.id && l.created_by === label.created_by,
|
||||
),
|
||||
)
|
||||
setFilteredChores(labelFiltered)
|
||||
setSearchFilter('Label: ' + label.name)
|
||||
setQuickFilter('label', [chipClicked.label.id])
|
||||
} else if (chipClicked.priority) {
|
||||
const priority = chipClicked.priority
|
||||
const priorityFiltered = baseChores.filter(
|
||||
chore => chore.priority === priority,
|
||||
)
|
||||
setFilteredChores(priorityFiltered)
|
||||
setSearchFilter('Priority: ' + priority)
|
||||
setQuickFilter('priority', [chipClicked.priority])
|
||||
}
|
||||
setSelectedCalendarDate(null)
|
||||
}
|
||||
@@ -623,8 +718,8 @@ const MyChores = () => {
|
||||
|
||||
const handleSearchChange = e => {
|
||||
clearActiveFilter()
|
||||
if (searchFilter !== 'All') {
|
||||
setSearchFilter('All')
|
||||
if (hasQuickFilters) {
|
||||
clearQuickFilters()
|
||||
}
|
||||
const search = e.target.value
|
||||
if (search === '') {
|
||||
@@ -672,14 +767,13 @@ const MyChores = () => {
|
||||
localStorage.setItem('openChoreSections', JSON.stringify(value))
|
||||
}
|
||||
|
||||
const toggleViewMode = () => {
|
||||
const modes = ['default', 'compact', 'calendar']
|
||||
const currentIndex = modes.indexOf(viewMode)
|
||||
const nextIndex = (currentIndex + 1) % modes.length
|
||||
const newMode = modes[nextIndex]
|
||||
const toggleViewMode = value => {
|
||||
const newMode = value ?? (() => {
|
||||
const modes = ['default', 'compact', 'calendar']
|
||||
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
|
||||
})()
|
||||
setViewMode(newMode)
|
||||
localStorage.setItem('choreCardViewMode', newMode)
|
||||
|
||||
if (newMode !== 'calendar') {
|
||||
setSelectedCalendarDate(null)
|
||||
}
|
||||
@@ -740,42 +834,6 @@ const MyChores = () => {
|
||||
// )
|
||||
// }
|
||||
|
||||
const getFilteredChores = useMemo(() => {
|
||||
if (activeFilterId || tempFilter) {
|
||||
return customFilteredChores
|
||||
}
|
||||
|
||||
let baseChores = projectFilteredChores
|
||||
|
||||
if (searchTerm?.length > 0 || searchFilter !== 'All') {
|
||||
if (searchTerm?.length > 0) {
|
||||
const projectFilteredForSearch = baseChores.map(c => ({
|
||||
...c,
|
||||
raw_label: c.labelsV2?.map(l => l.name).join(' '),
|
||||
}))
|
||||
const fuse = new Fuse(projectFilteredForSearch, {
|
||||
keys: ['name', 'raw_label'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
})
|
||||
return fuse.search(searchTerm).map(result => result.item)
|
||||
} else if (searchFilter !== 'All') {
|
||||
return filteredChores
|
||||
}
|
||||
}
|
||||
|
||||
return baseChores
|
||||
}, [
|
||||
activeFilterId,
|
||||
tempFilter,
|
||||
customFilteredChores,
|
||||
projectFilteredChores,
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
filteredChores,
|
||||
])
|
||||
|
||||
const getChoresForDate = useCallback(
|
||||
date => {
|
||||
const filteredChoresData = getFilteredChores
|
||||
@@ -800,7 +858,7 @@ const MyChores = () => {
|
||||
|
||||
setChores(newChores)
|
||||
setFilteredChores(newChores)
|
||||
setSearchFilter('All')
|
||||
clearQuickFilters()
|
||||
}
|
||||
|
||||
// Show error state when API is unreachable
|
||||
@@ -875,322 +933,96 @@ const MyChores = () => {
|
||||
tempFilter={tempFilter}
|
||||
tempFilterMeta={tempFilterMeta}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
<ChoreToolbar
|
||||
members={membersData?.res || []}
|
||||
labels={userLabels || []}
|
||||
projects={projectsWithDefault}
|
||||
tempFilter={tempFilter}
|
||||
tempFilterMeta={tempFilterMeta}
|
||||
applyTempFilter={applyTempFilter}
|
||||
clearTempFilter={clearTempFilter}
|
||||
saveFilter={saveFilter}
|
||||
updateFilter={updateFilter}
|
||||
onFilterSaved={name =>
|
||||
showSuccess({
|
||||
title: 'Filter Saved',
|
||||
message: `"${name}" has been saved`,
|
||||
})
|
||||
}
|
||||
onClearAllFilters={() => {
|
||||
clearQuickFilters()
|
||||
clearActiveFilter()
|
||||
setSelectedChoreFilterWithCache('anyone')
|
||||
setSelectedProjectWithCache(
|
||||
projectsWithDefault.find(p => p.id === 'default') || null,
|
||||
)
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
>
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
onClose={handleSearchClose}
|
||||
onFocus={() => setShowSearchFilter(true)}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
inputRef={searchInputRef}
|
||||
/>
|
||||
|
||||
<SortAndGrouping
|
||||
title='Group by'
|
||||
k={'icon-menu-group-by'}
|
||||
icon={<Sort />}
|
||||
selectedItem={selectedChoreSection}
|
||||
selectedFilter={selectedChoreFilter}
|
||||
setFilter={filter => {
|
||||
setSelectedChoreFilterWithCache(filter)
|
||||
// Clear active custom filter when quick filter is applied
|
||||
if (activeFilterId) {
|
||||
clearActiveFilter()
|
||||
updateFilterUrl(null, null)
|
||||
}
|
||||
}}
|
||||
onItemSelect={selected => {
|
||||
setSelectedChoreSectionWithCache(selected.value)
|
||||
setFilteredChores(chores)
|
||||
setSearchFilter('All')
|
||||
}}
|
||||
onCreateNewFilter={() => {
|
||||
setShowAdvancedFilterBuilder(true)
|
||||
setEditingFilter(null)
|
||||
}}
|
||||
mouseClickHandler={handleMenuOutsideClick}
|
||||
/>
|
||||
|
||||
{/* Project Selector - Hidden when active filter has project conditions */}
|
||||
{projectsWithDefault.length > 1 &&
|
||||
!hasProjectConditions &&
|
||||
!hasFilterApplied && (
|
||||
<ProjectSelector
|
||||
selectedProject={selectedProject?.name || 'Default Project'}
|
||||
onProjectSelect={project => {
|
||||
setSelectedProjectWithCache(project)
|
||||
clearActiveFilter()
|
||||
}}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* View Mode Toggle Button */}
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 32,
|
||||
width: 32,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
onClick={toggleViewMode}
|
||||
title={
|
||||
viewMode === 'default'
|
||||
? 'Switch to Compact View'
|
||||
: viewMode === 'compact'
|
||||
? 'Switch to Calendar View'
|
||||
: 'Switch to Card View'
|
||||
resultCount={
|
||||
hasQuickFilters || hasFilterApplied
|
||||
? getFilteredChores.length
|
||||
: undefined
|
||||
}
|
||||
totalCount={
|
||||
hasQuickFilters || hasFilterApplied
|
||||
? projectFilteredChores.length
|
||||
: undefined
|
||||
}
|
||||
selectedProject={selectedProject}
|
||||
onProjectSelect={project => {
|
||||
setSelectedProjectWithCache(project)
|
||||
clearActiveFilter()
|
||||
}}
|
||||
selectedAssigneeFilter={selectedChoreFilter}
|
||||
onAssigneeFilterChange={filter => {
|
||||
setSelectedChoreFilterWithCache(filter)
|
||||
if (activeFilterId) {
|
||||
clearActiveFilter()
|
||||
updateFilterUrl(null, null)
|
||||
}
|
||||
>
|
||||
{viewMode === 'default' ? (
|
||||
<ViewAgenda />
|
||||
) : viewMode === 'compact' ? (
|
||||
<CalendarMonth />
|
||||
) : (
|
||||
<ViewModule />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
{/* Multi-select Toggle Button */}
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<IconButton
|
||||
variant={isMultiSelectMode ? 'solid' : 'outlined'}
|
||||
color={isMultiSelectMode ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 32,
|
||||
width: 32,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
onClick={toggleMultiSelectMode}
|
||||
title={
|
||||
isMultiSelectMode
|
||||
? 'Exit Multi-select Mode (Ctrl+S)'
|
||||
: 'Enable Multi-select Mode (Ctrl+S)'
|
||||
}
|
||||
>
|
||||
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
||||
</IconButton>
|
||||
<KeyboardShortcutHint
|
||||
shortcut='S'
|
||||
show={showKeyboardShortcuts}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Search Filter with animation */}
|
||||
<Box
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
maxHeight: showSearchFilter ? '150px' : '0',
|
||||
opacity: showSearchFilter ? 1 : 0,
|
||||
transform: showSearchFilter ? 'translateY(0)' : 'translateY(-10px)',
|
||||
marginBottom: showSearchFilter ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-4'>
|
||||
<div className='grid flex-1 grid-cols-3 gap-4'>
|
||||
<IconButtonWithMenu
|
||||
label={' Priority'}
|
||||
k={'icon-menu-priority-filter'}
|
||||
icon={<PriorityHigh />}
|
||||
options={Priorities}
|
||||
selectedItem={searchFilter}
|
||||
onItemSelect={selected => {
|
||||
handleLabelFiltering({ priority: selected.value })
|
||||
}}
|
||||
mouseClickHandler={handleMenuOutsideClick}
|
||||
isActive={searchFilter.startsWith('Priority: ')}
|
||||
/>
|
||||
|
||||
<IconButtonWithMenu
|
||||
k={'icon-menu-labels-filter'}
|
||||
label={' Labels'}
|
||||
icon={<Style />}
|
||||
options={userLabels}
|
||||
selectedItem={searchFilter}
|
||||
onItemSelect={selected => {
|
||||
handleLabelFiltering({ label: selected })
|
||||
}}
|
||||
isActive={searchFilter.startsWith('Label: ')}
|
||||
mouseClickHandler={handleMenuOutsideClick}
|
||||
useChips
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleFilterMenuOpen}
|
||||
variant='outlined'
|
||||
startDecorator={<Grain />}
|
||||
color={
|
||||
searchFilter && FILTERS[searchFilter] && searchFilter != 'All'
|
||||
? 'primary'
|
||||
: 'neutral'
|
||||
}
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 24,
|
||||
borderRadius: 24,
|
||||
}}
|
||||
>
|
||||
{' Other'}
|
||||
</Button>
|
||||
|
||||
<List
|
||||
orientation='horizontal'
|
||||
wrap
|
||||
sx={{
|
||||
mt: 0.2,
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleFilterMenuClose}
|
||||
>
|
||||
{Object.keys(FILTERS).map((filter, index) => (
|
||||
<MenuItem
|
||||
key={`filter-list-${filter}-${index}`}
|
||||
onClick={() => {
|
||||
const filterFunction = FILTERS[filter]
|
||||
const baseChores = selectedProject
|
||||
? projectFilteredChores
|
||||
: chores
|
||||
const filteredChores =
|
||||
filterFunction.length === 2
|
||||
? filterFunction(baseChores, userProfile?.id)
|
||||
: filterFunction(baseChores)
|
||||
setFilteredChores(filteredChores)
|
||||
setSearchFilter(filter)
|
||||
handleFilterMenuClose()
|
||||
|
||||
// Update URL with legacy filter parameter
|
||||
const filterMap = {
|
||||
'No Due Date': 'unplanned',
|
||||
Overdue: 'overdue',
|
||||
'Due today': 'today',
|
||||
'Due in week': 'week',
|
||||
'Due Later': 'later',
|
||||
'Pending Approval': 'pending',
|
||||
}
|
||||
const urlFilter = filterMap[filter]
|
||||
if (urlFilter) {
|
||||
updateFilterUrl('filter', urlFilter)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{filter}
|
||||
<Chip
|
||||
color={searchFilter === filter ? 'primary' : 'neutral'}
|
||||
>
|
||||
{(() => {
|
||||
const baseChores = selectedProject
|
||||
? projectFilteredChores
|
||||
: chores
|
||||
return FILTERS[filter].length === 2
|
||||
? FILTERS[filter](baseChores, userProfile?.id)
|
||||
.length
|
||||
: FILTERS[filter](baseChores).length
|
||||
})()}
|
||||
</Chip>
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
{searchFilter.startsWith('Label: ') ||
|
||||
(searchFilter.startsWith('Priority: ') && (
|
||||
<MenuItem
|
||||
key={`filter-list-cancel-all-filters`}
|
||||
onClick={() => {
|
||||
setFilteredChores(
|
||||
selectedProject ? projectFilteredChores : chores,
|
||||
)
|
||||
setSearchFilter('All')
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
>
|
||||
Cancel All Filters
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</List>
|
||||
</div>
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 24,
|
||||
borderRadius: 24,
|
||||
}}
|
||||
onClick={() => {
|
||||
setShowSearchFilter(false)
|
||||
setSearchTerm('')
|
||||
setFilteredChores(chores)
|
||||
setSearchFilter('All')
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
>
|
||||
<CancelRounded />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Custom Filters Section */}
|
||||
<FilterSection
|
||||
savedFilters={savedFilters}
|
||||
activeFilterId={activeFilterId}
|
||||
activeFilter={activeFilter}
|
||||
hasProjectConditions={hasProjectConditions}
|
||||
onFilterClick={filterId => {
|
||||
onSavedFilterClick={filterId => {
|
||||
if (activeFilterId === filterId) {
|
||||
clearActiveFilter()
|
||||
updateFilterUrl(null, null)
|
||||
} else {
|
||||
setSearchFilter('All')
|
||||
clearQuickFilters()
|
||||
setSearchTerm('')
|
||||
setFilteredChores([])
|
||||
|
||||
// Reset quick filter to 'anyone' when custom filter is applied
|
||||
if (selectedChoreFilter !== 'anyone') {
|
||||
setSelectedChoreFilterWithCache('anyone')
|
||||
}
|
||||
|
||||
// Clear project selection if the filter has project conditions
|
||||
const filter = savedFilters.find(f => f.id === filterId)
|
||||
if (filter?.conditions?.some(c => c.type === 'project')) {
|
||||
setSelectedProjectWithCache(null)
|
||||
}
|
||||
|
||||
applyCustomFilter(filterId)
|
||||
updateFilterUrl('filterId', filterId)
|
||||
}
|
||||
}}
|
||||
onFilterDelete={deleteFilter}
|
||||
onFilterPin={pinFilter}
|
||||
onFilterEdit={filter => {
|
||||
onSavedFilterEdit={filter => {
|
||||
setEditingFilter(filter)
|
||||
setShowAdvancedFilterBuilder(true)
|
||||
}}
|
||||
onClearActiveFilter={clearActiveFilter}
|
||||
onCreateAdvancedFilter={() => setShowAdvancedFilterBuilder(true)}
|
||||
updateFilterUrl={updateFilterUrl}
|
||||
onSavedFilterDelete={deleteFilter}
|
||||
onSavedFilterPin={pinFilter}
|
||||
selectedGroupBy={selectedChoreSection}
|
||||
onGroupBySelect={value => {
|
||||
setSelectedChoreSectionWithCache(value)
|
||||
setFilteredChores(chores)
|
||||
clearQuickFilters()
|
||||
}}
|
||||
viewMode={viewMode}
|
||||
onToggleViewMode={toggleViewMode}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
onToggleMultiSelect={toggleMultiSelectMode}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearchClose={handleSearchClose}
|
||||
searchInputRef={searchInputRef}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
/>
|
||||
|
||||
<MultiSelectToolbar
|
||||
@@ -1204,41 +1036,15 @@ const MyChores = () => {
|
||||
onDelete={handleBulkDelete}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
selectAllDisabled={
|
||||
searchTerm?.length > 0 || searchFilter !== 'All'
|
||||
? selectedChores.size === filteredChores.length
|
||||
searchTerm?.length > 0 || hasQuickFilters
|
||||
? selectedChores.size === getFilteredChores.length
|
||||
: selectedChores.size ===
|
||||
choreSections.flatMap(s => s.content || []).length
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Additional Filters Display */}
|
||||
{searchFilter !== 'All' && (
|
||||
<Chip
|
||||
level='title-md'
|
||||
gutterBottom
|
||||
color='warning'
|
||||
label={searchFilter}
|
||||
onDelete={() => {
|
||||
setFilteredChores(
|
||||
selectedProject ? projectFilteredChores : chores,
|
||||
)
|
||||
setSearchFilter('All')
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
endDecorator={<CancelRounded />}
|
||||
onClick={() => {
|
||||
setFilteredChores(
|
||||
selectedProject ? projectFilteredChores : chores,
|
||||
)
|
||||
setSearchFilter('All')
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
>
|
||||
Additional Filter: {searchFilter}
|
||||
</Chip>
|
||||
)}
|
||||
{/* Show "Nothing scheduled" when appropriate based on current view mode */}
|
||||
{(searchTerm?.length > 0 || searchFilter !== 'All' || activeFilterId
|
||||
{(searchTerm?.length > 0 || hasQuickFilters || activeFilterId
|
||||
? getFilteredChores.length === 0
|
||||
: projectFilteredChores.length === 0) &&
|
||||
// only if not in calendar view:
|
||||
@@ -1266,10 +1072,9 @@ const MyChores = () => {
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearchFilter('All')
|
||||
clearQuickFilters()
|
||||
setSearchTerm('')
|
||||
clearActiveFilter()
|
||||
// reset project and filters :
|
||||
setSelectedProjectWithCache(null)
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
@@ -1604,6 +1409,7 @@ const MyChores = () => {
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
data-testid='open-add-task-modal'
|
||||
color='primary'
|
||||
variant='soft'
|
||||
sx={{
|
||||
@@ -1719,59 +1525,4 @@ const MyChores = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const FILTERS = {
|
||||
All: function (chores) {
|
||||
return chores
|
||||
},
|
||||
Overdue: function (chores) {
|
||||
return chores.filter(chore => {
|
||||
if (chore.nextDueDate === null) return false
|
||||
return new Date(chore.nextDueDate) < new Date()
|
||||
})
|
||||
},
|
||||
'Due today': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return (
|
||||
new Date(chore.nextDueDate).toDateString() === new Date().toDateString()
|
||||
)
|
||||
})
|
||||
},
|
||||
'Due in week': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return (
|
||||
new Date(chore.nextDueDate) <
|
||||
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) &&
|
||||
new Date(chore.nextDueDate) > new Date()
|
||||
)
|
||||
})
|
||||
},
|
||||
'Due Later': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return (
|
||||
new Date(chore.nextDueDate) > new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
)
|
||||
})
|
||||
},
|
||||
'Created By Me': function (chores, userID) {
|
||||
return chores.filter(chore => {
|
||||
return chore.createdBy === userID
|
||||
})
|
||||
},
|
||||
'Assigned To Me': function (chores, userID) {
|
||||
return chores.filter(chore => {
|
||||
return chore.assignedTo === userID
|
||||
})
|
||||
},
|
||||
'No Due Date': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return chore.nextDueDate === null
|
||||
})
|
||||
},
|
||||
'Pending Approval': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return chore.status === 3
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default MyChores
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import DateModal from '../../Modals/Inputs/DateModal'
|
||||
import NudgeModal from '../../Modals/Inputs/NudgeModal'
|
||||
import SelectModal from '../../Modals/Inputs/SelectModal'
|
||||
import TextModal from '../../Modals/Inputs/TextModal'
|
||||
import WriteNFCModal from '../../Modals/Inputs/WriteNFCModal'
|
||||
|
||||
const getNFCUrl = choreId =>
|
||||
Capacitor.getPlatform() === 'android'
|
||||
? `donetick://chores/${choreId}`
|
||||
: `${window.location.origin}/chores/${choreId}`
|
||||
|
||||
const ChoreModals = ({
|
||||
activeModal,
|
||||
modalChore,
|
||||
@@ -65,12 +71,13 @@ const ChoreModals = ({
|
||||
<WriteNFCModal
|
||||
config={{
|
||||
isOpen: true,
|
||||
url: `${window.location.origin}/chores/${modalChore.id}`,
|
||||
url: getNFCUrl(modalChore.id),
|
||||
onClose: onClose,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{activeModal === 'nudge' && modalChore && (
|
||||
<NudgeModal
|
||||
config={{
|
||||
|
||||
920
src/views/Chores/components/ChoreToolbarPrototype.jsx
Normal file
@@ -0,0 +1,920 @@
|
||||
/**
|
||||
* PROTOTYPE – Unified Chore Toolbar
|
||||
*
|
||||
* Proposed design to replace the current 3-surface layout:
|
||||
* OLD: [Search] [Sort+Group+AssigneeFilter+CreateFilter] [ProjectSelector] [View] [Multiselect]
|
||||
* + FilterBar (Due Date / Priority / Labels chips row)
|
||||
* + FilterSection (saved/pinned filter chips row)
|
||||
*
|
||||
* NEW: [Search] [Filter(n)] [Group ▾] [View] [Multiselect]
|
||||
* + active filter chips appear inline next to Filter button
|
||||
* + Filter button opens ONE unified bottom sheet containing:
|
||||
* Assignee · Created By · Status · Priority · Due Date · Labels · Projects · Points
|
||||
* + Saved Filters section
|
||||
*
|
||||
* How to try it: in MyChores.jsx, replace the <Box sx={{display:'flex'...}}> toolbar block
|
||||
* and the two rows below it (FilterBar + FilterSection) with:
|
||||
* <ChoreToolbar ... />
|
||||
*/
|
||||
|
||||
import {
|
||||
ArrowDropDown,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
FilterList,
|
||||
Save,
|
||||
Sort,
|
||||
Tune,
|
||||
ViewAgenda,
|
||||
ViewComfy,
|
||||
ViewModule,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Chip,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import BottomSheetModal from '../../../components/common/BottomSheetModal'
|
||||
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
|
||||
import { Z_INDEX } from '../../../constants/zIndex'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import { FILTER_COLORS } from '../../../utils/Colors'
|
||||
import Priorities from '../../../utils/Priorities'
|
||||
import FilterBuilderContent, {
|
||||
CHORE_STATUSES,
|
||||
DUE_DATE_OPTIONS,
|
||||
POINTS_OPERATORS,
|
||||
conditionsToSelections,
|
||||
defaultSelections,
|
||||
selectionsToConditions,
|
||||
} from './FilterBuilderContent'
|
||||
import SearchBar from './SearchBar'
|
||||
import ProjectSelector from '../../components/ProjectSelector'
|
||||
|
||||
// ─── sub-components for the Display sheet ────────────────────────────────────
|
||||
|
||||
const SectionHeader = ({ icon, label, badge }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
{icon && (
|
||||
<Box
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'& svg': { fontSize: 18 },
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Typography level='title-sm' fontWeight={600}>
|
||||
{label}
|
||||
</Typography>
|
||||
{badge != null && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
sx={{ ml: 'auto', fontSize: '0.7rem', height: 20 }}
|
||||
>
|
||||
{badge}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
|
||||
const OptionChips = ({ options, selected, multi, onToggle }) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{options.map(opt => {
|
||||
const isSelected = multi
|
||||
? (selected || []).includes(opt.value)
|
||||
: selected === opt.value
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? opt.color ?? 'primary' : 'neutral'}
|
||||
startDecorator={
|
||||
opt.icon != null
|
||||
? isSelected
|
||||
? <Check sx={{ fontSize: 14 }} />
|
||||
: opt.icon
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onToggle(opt.value)}
|
||||
sx={{
|
||||
py: 0.64,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
|
||||
// ─── main component ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* -- Advanced filter (filter sheet) --
|
||||
* members – circle members for Assignee / Created By sections
|
||||
* labels – user labels for Labels section
|
||||
* projects – projects list (projectsWithDefault) for Projects section + Display sheet
|
||||
* tempFilter – current temp filter object { conditions, operator } or null
|
||||
* tempFilterMeta – metadata for temp filter, including saved-filter edit source when applicable
|
||||
* applyTempFilter – (filter) => void — called immediately as selections change
|
||||
* clearTempFilter – () => void
|
||||
* saveFilter – (filterData) => Promise — saves as a named filter
|
||||
* updateFilter – (filterId, filterData) => Promise — updates an existing saved filter
|
||||
* onFilterSaved – (name) => void — called after successful save (for notifications)
|
||||
*
|
||||
* -- Result counts --
|
||||
* resultCount / totalCount
|
||||
*
|
||||
* -- Clear all --
|
||||
* onClearAllFilters – () => void
|
||||
*
|
||||
* -- Saved filters --
|
||||
* savedFilters – [{ id, name, color, count, isPinned }]
|
||||
* activeFilterId – number | null
|
||||
* onSavedFilterClick – (id) => void
|
||||
* onSavedFilterEdit – (filter) => void
|
||||
* onSavedFilterDelete– (id) => void
|
||||
* onSavedFilterPin – (id) => void
|
||||
*
|
||||
* -- Display sheet --
|
||||
* selectedProject – current project object (for Display sheet section)
|
||||
* onProjectSelect – (project) => void
|
||||
* selectedAssigneeFilter – 'anyone' | 'assigned_to_me' | 'available_for_me' | 'assigned_to_others'
|
||||
* onAssigneeFilterChange – (key) => void
|
||||
* selectedGroupBy – 'default' | 'due_date' | 'priority' | 'labels'
|
||||
* onGroupBySelect – (value) => void
|
||||
* viewMode – 'default' | 'compact' | 'calendar'
|
||||
* onToggleViewMode – (value?) => void
|
||||
*
|
||||
* -- Multi-select --
|
||||
* isMultiSelectMode – bool
|
||||
* onToggleMultiSelect – () => void
|
||||
*
|
||||
* -- Search --
|
||||
* searchTerm / onSearchChange / onSearchClose / searchInputRef
|
||||
* showKeyboardShortcuts
|
||||
*/
|
||||
const ChoreToolbar = ({
|
||||
// advanced filter
|
||||
members = [],
|
||||
labels = [],
|
||||
projects = [],
|
||||
tempFilter,
|
||||
tempFilterMeta,
|
||||
applyTempFilter,
|
||||
clearTempFilter,
|
||||
saveFilter,
|
||||
updateFilter,
|
||||
onFilterSaved,
|
||||
// result counts
|
||||
resultCount,
|
||||
totalCount,
|
||||
// clear all
|
||||
onClearAllFilters,
|
||||
// project (for Display sheet)
|
||||
selectedProject,
|
||||
onProjectSelect,
|
||||
// assignee (for Display sheet)
|
||||
selectedAssigneeFilter = 'anyone',
|
||||
onAssigneeFilterChange,
|
||||
// saved / custom
|
||||
savedFilters = [],
|
||||
activeFilterId,
|
||||
onSavedFilterClick,
|
||||
onSavedFilterEdit,
|
||||
onSavedFilterDelete,
|
||||
onSavedFilterPin,
|
||||
// grouping
|
||||
selectedGroupBy = 'default',
|
||||
onGroupBySelect,
|
||||
// view + multiselect
|
||||
viewMode = 'default',
|
||||
onToggleViewMode,
|
||||
isMultiSelectMode,
|
||||
onToggleMultiSelect,
|
||||
// search
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
onSearchClose,
|
||||
searchInputRef,
|
||||
showKeyboardShortcuts,
|
||||
}) => {
|
||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false)
|
||||
const [displaySheetOpen, setDisplaySheetOpen] = useState(false)
|
||||
const [localSelections, setLocalSelections] = useState(defaultSelections())
|
||||
const [savingFilter, setSavingFilter] = useState(false)
|
||||
const [saveFilterName, setSaveFilterName] = useState('')
|
||||
const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null)
|
||||
const [editingSavedFilter, setEditingSavedFilter] = useState(null)
|
||||
const saveMenuRef = useRef(null)
|
||||
const activeConditions = selectionsToConditions(localSelections)
|
||||
|
||||
// ── badge counts ─────────────────────────────────────────────────────────────
|
||||
|
||||
const tempConditionCount = tempFilter?.conditions?.length || 0
|
||||
const savedFilterActive = activeFilterId != null ? 1 : 0
|
||||
const totalActiveCount = tempConditionCount + savedFilterActive
|
||||
const hasAnyActive = totalActiveCount > 0
|
||||
|
||||
// ── inline chip strip ────────────────────────────────────────────────────────
|
||||
|
||||
const inlineChips = []
|
||||
|
||||
const getConditionChipLabel = condition => {
|
||||
if (!condition?.type) return 'Filter'
|
||||
|
||||
const typeLabels = {
|
||||
assignee: 'Assignee',
|
||||
createdBy: 'Created By',
|
||||
status: 'Status',
|
||||
priority: 'Priority',
|
||||
label: 'Labels',
|
||||
project: 'Project',
|
||||
dueDate: 'Due Date',
|
||||
points: 'Points',
|
||||
}
|
||||
|
||||
const typeLabel = typeLabels[condition.type] || 'Filter'
|
||||
const prefix = condition.operator === 'isNot' ? 'Not ' : ''
|
||||
|
||||
if (condition.type === 'dueDate') {
|
||||
const dueDateLabel =
|
||||
DUE_DATE_OPTIONS.find(o => o.value === condition.operator)?.label ||
|
||||
'Custom'
|
||||
return `${typeLabel}: ${dueDateLabel}`
|
||||
}
|
||||
|
||||
if (condition.type === 'points') {
|
||||
const pointsOp =
|
||||
POINTS_OPERATORS.find(o => o.value === condition.operator)?.label ||
|
||||
condition.operator ||
|
||||
''
|
||||
return `${typeLabel} ${pointsOp} ${condition.value ?? 0}`
|
||||
}
|
||||
|
||||
const rawValues = Array.isArray(condition.value)
|
||||
? condition.value
|
||||
: condition.value != null
|
||||
? [condition.value]
|
||||
: []
|
||||
|
||||
const resolveLabel = value => {
|
||||
if (condition.type === 'assignee' || condition.type === 'createdBy') {
|
||||
const member = members.find(m => m.userId === value)
|
||||
return member?.displayName || member?.username || String(value)
|
||||
}
|
||||
if (condition.type === 'status') {
|
||||
return CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
|
||||
}
|
||||
if (condition.type === 'priority') {
|
||||
return Priorities.find(p => p.value === value)?.name || String(value)
|
||||
}
|
||||
if (condition.type === 'label') {
|
||||
return labels.find(l => l.id === value)?.name || String(value)
|
||||
}
|
||||
if (condition.type === 'project') {
|
||||
if (value === 'default') return 'Default Project'
|
||||
return projects.find(p => p.id === value)?.name || String(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
if (rawValues.length === 0) {
|
||||
return `${prefix}${typeLabel}`
|
||||
}
|
||||
|
||||
if (rawValues.length === 1) {
|
||||
return `${prefix}${typeLabel}: ${resolveLabel(rawValues[0])}`
|
||||
}
|
||||
|
||||
return `${prefix}${typeLabel} (${rawValues.length})`
|
||||
}
|
||||
|
||||
const clearConditionAtIndex = index => {
|
||||
const nextConditions = (tempFilter?.conditions || []).filter(
|
||||
(_condition, conditionIndex) => conditionIndex !== index,
|
||||
)
|
||||
|
||||
if (nextConditions.length === 0) {
|
||||
setLocalSelections(defaultSelections())
|
||||
clearTempFilter?.()
|
||||
return
|
||||
}
|
||||
|
||||
const nextFilter = {
|
||||
...tempFilter,
|
||||
operator: tempFilter?.operator || 'AND',
|
||||
conditions: nextConditions,
|
||||
}
|
||||
|
||||
setLocalSelections(conditionsToSelections(nextConditions))
|
||||
applyTempFilter?.(nextFilter)
|
||||
}
|
||||
|
||||
const activeSavedFilter = savedFilterActive
|
||||
? savedFilters.find(f => f.id === activeFilterId)
|
||||
: null
|
||||
|
||||
const activeChipConditions = savedFilterActive
|
||||
? activeSavedFilter?.conditions || []
|
||||
: tempFilter?.conditions || []
|
||||
|
||||
activeChipConditions.forEach((condition, index) => {
|
||||
inlineChips.push({
|
||||
key: `${savedFilterActive ? '__saved' : '__temp'}_${index}`,
|
||||
label: getConditionChipLabel(condition),
|
||||
onClear: () => {
|
||||
if (savedFilterActive) {
|
||||
onSavedFilterClick?.(activeFilterId)
|
||||
return
|
||||
}
|
||||
clearConditionAtIndex(index)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ── open filter sheet ────────────────────────────────────────────────────────
|
||||
|
||||
const openFilterSheet = () => {
|
||||
if (tempFilter?.conditions?.length > 0) {
|
||||
setLocalSelections(conditionsToSelections(tempFilter.conditions))
|
||||
if (tempFilterMeta?.sourceFilterId) {
|
||||
const sourceFilter =
|
||||
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) ||
|
||||
null
|
||||
setEditingSavedFilter(
|
||||
sourceFilter ||
|
||||
(tempFilterMeta.sourceFilterId
|
||||
? {
|
||||
id: tempFilterMeta.sourceFilterId,
|
||||
name: tempFilterMeta.sourceFilterName,
|
||||
description: tempFilterMeta.sourceFilterDescription,
|
||||
color: tempFilterMeta.sourceFilterColor,
|
||||
}
|
||||
: null),
|
||||
)
|
||||
} else {
|
||||
setEditingSavedFilter(null)
|
||||
}
|
||||
} else if (activeFilterId) {
|
||||
const sf = savedFilters.find(f => f.id === activeFilterId)
|
||||
setEditingSavedFilter(sf || null)
|
||||
setLocalSelections(
|
||||
sf?.conditions
|
||||
? conditionsToSelections(sf.conditions)
|
||||
: defaultSelections(),
|
||||
)
|
||||
} else {
|
||||
setEditingSavedFilter(null)
|
||||
setLocalSelections(defaultSelections())
|
||||
}
|
||||
setSavingFilter(false)
|
||||
setSaveFilterName('')
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterSheetOpen || savingFilter || activeConditions.length === 0) {
|
||||
setSaveMenuAnchorEl(null)
|
||||
}
|
||||
}, [filterSheetOpen, savingFilter, activeConditions.length])
|
||||
|
||||
// ── selection changes → apply temp filter immediately ────────────────────────
|
||||
|
||||
const handleSelectionsChange = updater => {
|
||||
setLocalSelections(prev => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater
|
||||
const conditions = selectionsToConditions(next)
|
||||
if (conditions.length > 0) {
|
||||
applyTempFilter?.(
|
||||
{ conditions, operator: 'AND' },
|
||||
editingSavedFilter
|
||||
? {
|
||||
name: editingSavedFilter.name,
|
||||
description: editingSavedFilter.description,
|
||||
sourceFilterId: editingSavedFilter.id,
|
||||
sourceFilterName: editingSavedFilter.name,
|
||||
sourceFilterDescription: editingSavedFilter.description,
|
||||
sourceFilterColor: editingSavedFilter.color,
|
||||
isEditingSavedFilter: true,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
} else {
|
||||
clearTempFilter?.()
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// ── save filter ───────────────────────────────────────────────────────────────
|
||||
|
||||
const handleSaveFilter = () => {
|
||||
const name = saveFilterName.trim()
|
||||
if (!name) return
|
||||
const conditions = selectionsToConditions(localSelections)
|
||||
if (conditions.length === 0) return
|
||||
|
||||
const usedColors = savedFilters.map(f => f.color)
|
||||
const color =
|
||||
FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ??
|
||||
FILTER_COLORS[0].value
|
||||
|
||||
saveFilter?.({ name, description: '', color, conditions, operator: 'AND' })?.then?.(() => {
|
||||
applyTempFilter?.({ conditions, operator: 'AND' }, { name })
|
||||
onFilterSaved?.(name)
|
||||
})
|
||||
|
||||
setSavingFilter(false)
|
||||
setSaveFilterName('')
|
||||
setFilterSheetOpen(false)
|
||||
}
|
||||
|
||||
const handleUpdateFilter = () => {
|
||||
if (!editingSavedFilter?.id || !updateFilter) return
|
||||
|
||||
const conditions = selectionsToConditions(localSelections)
|
||||
if (conditions.length === 0) return
|
||||
|
||||
updateFilter(
|
||||
editingSavedFilter.id,
|
||||
{
|
||||
name: editingSavedFilter.name,
|
||||
description: editingSavedFilter.description || '',
|
||||
color: editingSavedFilter.color,
|
||||
conditions,
|
||||
operator: 'AND',
|
||||
},
|
||||
)?.then?.(() => {
|
||||
clearTempFilter?.()
|
||||
onSavedFilterClick?.(editingSavedFilter.id)
|
||||
onFilterSaved?.(editingSavedFilter.name)
|
||||
})
|
||||
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(false)
|
||||
}
|
||||
|
||||
// ── display sheet helpers ────────────────────────────────────────────────────
|
||||
|
||||
const filterActive = activeFilterId != null || tempConditionCount > 0
|
||||
const projectActive = selectedProject && selectedProject.id !== 'default' ? 1 : 0
|
||||
const assigneeActive = selectedAssigneeFilter !== 'anyone' ? 1 : 0
|
||||
const displayActive =
|
||||
selectedGroupBy !== 'default' ||
|
||||
viewMode !== 'default' ||
|
||||
projectActive > 0 ||
|
||||
assigneeActive > 0
|
||||
|
||||
const groupByOptions = [
|
||||
{ value: 'default', label: 'Smart' },
|
||||
{ value: 'due_date', label: 'Due Date' },
|
||||
{ value: 'priority', label: 'Priority' },
|
||||
{ value: 'labels', label: 'Labels' },
|
||||
]
|
||||
|
||||
const assigneeOptions = [
|
||||
{ value: 'anyone', label: 'Everyone' },
|
||||
{ value: 'assigned_to_me', label: 'Mine' },
|
||||
{ value: 'available_for_me', label: 'Available to me' },
|
||||
{ value: 'assigned_to_others', label: 'Others' },
|
||||
]
|
||||
|
||||
const viewOptions = [
|
||||
{ value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> },
|
||||
{ value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> },
|
||||
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Row 1: main toolbar ─────────────────────────────────────────────── */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{/* Search takes available space */}
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChange={onSearchChange}
|
||||
onClose={onSearchClose}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
inputRef={searchInputRef}
|
||||
/>
|
||||
|
||||
{/* Filter button */}
|
||||
<Badge
|
||||
badgeContent={totalActiveCount || null}
|
||||
color='primary'
|
||||
size='sm'
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<IconButton
|
||||
variant={hasAnyActive ? 'solid' : 'outlined'}
|
||||
color={hasAnyActive ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={openFilterSheet}
|
||||
title='Filters'
|
||||
>
|
||||
<FilterList />
|
||||
</IconButton>
|
||||
</Badge>
|
||||
|
||||
{/* Project selector */}
|
||||
{!filterActive && projects.filter(p => p.id !== 'default').length > 0 && (
|
||||
<ProjectSelector
|
||||
selectedProject={selectedProject?.name || 'Default Project'}
|
||||
onProjectSelect={onProjectSelect}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Display button — View + Group combined */}
|
||||
<IconButton
|
||||
variant={displayActive ? 'solid' : 'outlined'}
|
||||
color={displayActive ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={() => setDisplaySheetOpen(true)}
|
||||
title='View & Group'
|
||||
>
|
||||
{viewMode === 'calendar' ? (
|
||||
<CalendarMonth />
|
||||
) : viewMode === 'compact' ? (
|
||||
<ViewModule />
|
||||
) : (
|
||||
<ViewAgenda />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
{/* Multiselect */}
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<IconButton
|
||||
variant={isMultiSelectMode ? 'solid' : 'outlined'}
|
||||
color={isMultiSelectMode ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={onToggleMultiSelect}
|
||||
title={
|
||||
isMultiSelectMode
|
||||
? 'Exit multi-select (Ctrl+S)'
|
||||
: 'Multi-select (Ctrl+S)'
|
||||
}
|
||||
>
|
||||
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
||||
</IconButton>
|
||||
<KeyboardShortcutHint
|
||||
shortcut='S'
|
||||
show={showKeyboardShortcuts}
|
||||
sx={{ position: 'absolute', top: -8, right: -8, zIndex: 1000 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ── Row 2: active filter chips ──────────────────────────────────────── */}
|
||||
{hasAnyActive && (
|
||||
<ActiveFilterChips
|
||||
chips={inlineChips}
|
||||
onOpen={openFilterSheet}
|
||||
onClearAll={() => {
|
||||
setLocalSelections(defaultSelections())
|
||||
onClearAllFilters?.()
|
||||
}}
|
||||
resultCount={resultCount}
|
||||
totalCount={totalCount}
|
||||
maxVisible={2}
|
||||
chipSize='md'
|
||||
clearButtonSize='sm'
|
||||
clearButtonSx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
|
||||
<BottomSheetModal
|
||||
open={filterSheetOpen}
|
||||
onClose={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
maxHeight='92vh'
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tune sx={{ fontSize: 20 }} />
|
||||
Filters
|
||||
{hasAnyActive && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 0.5 }}>
|
||||
{totalActiveCount}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
savingFilter ? (
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 1, width: '100%', alignItems: 'center' }}
|
||||
>
|
||||
<Input
|
||||
size='sm'
|
||||
placeholder='Filter name…'
|
||||
value={saveFilterName}
|
||||
onChange={e => setSaveFilterName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSaveFilter()}
|
||||
autoFocus
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={handleSaveFilter}
|
||||
disabled={!saveFilterName.trim()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={() => setSavingFilter(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
disabled={!hasAnyActive && activeConditions.length === 0}
|
||||
onClick={() => {
|
||||
setLocalSelections(defaultSelections())
|
||||
setSaveMenuAnchorEl(null)
|
||||
onClearAllFilters?.()
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
|
||||
{activeConditions.length > 0 ? (
|
||||
<>
|
||||
<ButtonGroup variant='solid' color='primary'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
{resultCount != null
|
||||
? `Show ${resultCount}`
|
||||
: 'Done'}
|
||||
</Button>
|
||||
<IconButton
|
||||
ref={saveMenuRef}
|
||||
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
|
||||
>
|
||||
<ArrowDropDown />
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
|
||||
<Menu
|
||||
anchorEl={saveMenuAnchorEl}
|
||||
open={Boolean(saveMenuAnchorEl)}
|
||||
onClose={() => setSaveMenuAnchorEl(null)}
|
||||
placement='top-end'
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={handleUpdateFilter}
|
||||
disabled={!editingSavedFilter}
|
||||
>
|
||||
<Save sx={{ fontSize: 16, mr: 1 }} />
|
||||
Save Filter
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveFilterName(
|
||||
editingSavedFilter
|
||||
? `${editingSavedFilter.name} Copy`
|
||||
: '',
|
||||
)
|
||||
setSavingFilter(true)
|
||||
}}
|
||||
>
|
||||
<Save sx={{ fontSize: 16, mr: 1 }} />
|
||||
Save as New Filter
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Full advanced filter content */}
|
||||
<FilterBuilderContent
|
||||
selections={localSelections}
|
||||
onSelectionsChange={handleSelectionsChange}
|
||||
members={members}
|
||||
labels={labels}
|
||||
projects={projects}
|
||||
/>
|
||||
|
||||
{/* Saved filters section */}
|
||||
{savedFilters.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
<Typography level='title-sm' fontWeight={600} sx={{ mb: 1.5 }}>
|
||||
Saved Filters
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{savedFilters.map(filter => {
|
||||
const isActive = activeFilterId === filter.id
|
||||
return (
|
||||
<Chip
|
||||
key={filter.id}
|
||||
variant={isActive ? 'solid' : 'soft'}
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
isActive ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (
|
||||
<Chip size='sm' variant='plain' color='neutral'>
|
||||
{filter.count ?? 0}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
onSavedFilterClick?.(filter.id)
|
||||
if (!isActive) setFilterSheetOpen(false)
|
||||
}}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
...(filter.color && !isActive
|
||||
? { borderColor: filter.color }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{filter.name}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
|
||||
{/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
|
||||
<BottomSheetModal
|
||||
open={displaySheetOpen}
|
||||
onClose={() => setDisplaySheetOpen(false)}
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ViewAgenda sx={{ fontSize: 20 }} />
|
||||
Display
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
<Button onClick={() => setDisplaySheetOpen(false)} sx={{ minWidth: 140 }}>
|
||||
Done
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* View section */}
|
||||
<SectionHeader label='View' />
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{viewOptions.map(opt => (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={viewMode === opt.value ? 'solid' : 'soft'}
|
||||
color={viewMode === opt.value ? 'primary' : 'neutral'}
|
||||
startDecorator={
|
||||
viewMode === opt.value
|
||||
? <Check sx={{ fontSize: 14 }} />
|
||||
: opt.icon
|
||||
}
|
||||
onClick={() => onToggleViewMode?.(opt.value)}
|
||||
sx={{
|
||||
py: 0.64,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
|
||||
{/* Group by section */}
|
||||
<SectionHeader
|
||||
icon={<Sort />}
|
||||
label='Group by'
|
||||
badge={
|
||||
selectedGroupBy !== 'default'
|
||||
? groupByOptions.find(o => o.value === selectedGroupBy)?.label
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{groupByOptions.map(opt => (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={selectedGroupBy === opt.value ? 'solid' : 'soft'}
|
||||
color={selectedGroupBy === opt.value ? 'primary' : 'neutral'}
|
||||
onClick={() => onGroupBySelect?.(opt.value)}
|
||||
sx={{
|
||||
py: 0.64,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Show tasks for section */}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
<SectionHeader
|
||||
icon={<FilterList />}
|
||||
label='Show tasks for'
|
||||
badge={
|
||||
selectedAssigneeFilter !== 'anyone'
|
||||
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)?.label
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<OptionChips
|
||||
options={assigneeOptions}
|
||||
selected={selectedAssigneeFilter}
|
||||
multi={false}
|
||||
onToggle={v => onAssigneeFilterChange?.(v)}
|
||||
/>
|
||||
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChoreToolbar
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Tooltip,
|
||||
Typography,
|
||||
IconButton,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
@@ -116,6 +116,8 @@ const CustomFilterChips = ({
|
||||
px: 1.0,
|
||||
py: 0.5,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
opacity: hasWarning ? 0.7 : isActive ? 1 : 0.85,
|
||||
...(hasCustomColor && {
|
||||
@@ -185,6 +187,10 @@ const CustomFilterChips = ({
|
||||
maxWidth: 100,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
lineHeight: 1,
|
||||
...(hasCustomColor && {
|
||||
color: textColor,
|
||||
}),
|
||||
|
||||
477
src/views/Chores/components/FilterBuilderContent.jsx
Normal file
@@ -0,0 +1,477 @@
|
||||
import {
|
||||
CalendarMonth,
|
||||
Check,
|
||||
FolderOpen,
|
||||
Label,
|
||||
Person,
|
||||
PriorityHigh,
|
||||
Stars,
|
||||
TaskAlt,
|
||||
} from '@mui/icons-material'
|
||||
import { Avatar, Box, Chip, Divider, Input, Typography } from '@mui/joy'
|
||||
import Priorities from '../../../utils/Priorities'
|
||||
|
||||
export const DUE_DATE_OPTIONS = [
|
||||
{ value: 'isOverdue', label: 'Overdue', color: 'danger' },
|
||||
{ value: 'isDueToday', label: 'Today', color: 'warning' },
|
||||
{ value: 'isDueTomorrow', label: 'Tomorrow', color: 'primary' },
|
||||
{ value: 'isDueThisWeek', label: 'This Week', color: 'primary' },
|
||||
{ value: 'isDueThisMonth', label: 'This Month', color: 'neutral' },
|
||||
{ value: 'hasNoDueDate', label: 'No Due Date', color: 'neutral' },
|
||||
{ value: 'hasDueDate', label: 'Has Due Date', color: 'neutral' },
|
||||
]
|
||||
|
||||
export const POINTS_OPERATORS = [
|
||||
{ value: 'greaterThan', label: '>' },
|
||||
{ value: 'greaterThanOrEqual', label: '>=' },
|
||||
{ value: 'equals', label: '=' },
|
||||
{ value: 'lessThanOrEqual', label: '<=' },
|
||||
{ value: 'lessThan', label: '<' },
|
||||
]
|
||||
|
||||
export const CHORE_STATUSES = [
|
||||
{ value: 0, label: 'Active' },
|
||||
{ value: 1, label: 'Started' },
|
||||
{ value: 2, label: 'In Progress' },
|
||||
{ value: 3, label: 'Pending Approval' },
|
||||
]
|
||||
|
||||
export const defaultSelections = () => ({
|
||||
assignee: { operator: 'is', values: [] },
|
||||
createdBy: { operator: 'is', values: [] },
|
||||
status: { operator: 'is', values: [] },
|
||||
priority: { operator: 'is', values: [] },
|
||||
label: { operator: 'is', values: [] },
|
||||
project: { operator: 'is', values: [] },
|
||||
dueDate: { operator: null },
|
||||
points: { operator: 'greaterThan', value: 0, active: false },
|
||||
})
|
||||
|
||||
export const conditionsToSelections = conditions => {
|
||||
const sel = defaultSelections()
|
||||
if (!conditions) return sel
|
||||
conditions.forEach(c => {
|
||||
if (c.type === 'dueDate') {
|
||||
sel.dueDate = { operator: c.operator }
|
||||
} else if (c.type === 'points') {
|
||||
sel.points = { operator: c.operator, value: c.value ?? 0, active: true }
|
||||
} else if (c.type in sel) {
|
||||
sel[c.type] = {
|
||||
operator: c.operator ?? 'is',
|
||||
values: Array.isArray(c.value)
|
||||
? c.value
|
||||
: c.value != null
|
||||
? [c.value]
|
||||
: [],
|
||||
}
|
||||
}
|
||||
})
|
||||
return sel
|
||||
}
|
||||
|
||||
export const selectionsToConditions = selections => {
|
||||
const conditions = []
|
||||
;['assignee', 'createdBy', 'status', 'priority', 'label', 'project'].forEach(
|
||||
type => {
|
||||
if (selections[type].values?.length > 0) {
|
||||
conditions.push({
|
||||
type,
|
||||
operator: selections[type].operator,
|
||||
value: selections[type].values,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
if (selections.dueDate.operator) {
|
||||
conditions.push({
|
||||
type: 'dueDate',
|
||||
operator: selections.dueDate.operator,
|
||||
value: null,
|
||||
})
|
||||
}
|
||||
if (selections.points.active) {
|
||||
conditions.push({
|
||||
type: 'points',
|
||||
operator: selections.points.operator,
|
||||
value: selections.points.value,
|
||||
})
|
||||
}
|
||||
return conditions
|
||||
}
|
||||
|
||||
const SectionHeader = ({ icon, label, children }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'& svg': { fontSize: 18 },
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
|
||||
const IncludeExcludeToggle = ({
|
||||
value,
|
||||
onChange,
|
||||
labels = ['Include', 'Exclude'],
|
||||
}) => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
|
||||
{[
|
||||
{ op: 'is', label: labels[0] },
|
||||
{ op: 'isNot', label: labels[1] },
|
||||
].map(o => (
|
||||
<Chip
|
||||
key={o.op}
|
||||
size='sm'
|
||||
variant={value === o.op ? 'solid' : 'soft'}
|
||||
color={
|
||||
value === o.op ? (o.op === 'isNot' ? 'danger' : 'primary') : 'neutral'
|
||||
}
|
||||
onClick={() => onChange(o.op)}
|
||||
sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }}
|
||||
>
|
||||
{o.label}
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
||||
/**
|
||||
* Reusable filter conditions UI used by both the filter sheet in ChoreToolbar
|
||||
* and the AdvancedFilterBuilder save modal.
|
||||
*
|
||||
* `onSelectionsChange` must accept either a new selections object or a
|
||||
* functional updater `prev => next` (same contract as React's setState setter).
|
||||
*/
|
||||
const FilterBuilderContent = ({
|
||||
selections,
|
||||
onSelectionsChange,
|
||||
members = [],
|
||||
labels = [],
|
||||
projects = [],
|
||||
}) => {
|
||||
const toggleValue = (type, value) =>
|
||||
onSelectionsChange(prev => {
|
||||
const cur = prev[type].values || []
|
||||
const next = cur.includes(value)
|
||||
? cur.filter(v => v !== value)
|
||||
: [...cur, value]
|
||||
return { ...prev, [type]: { ...prev[type], values: next } }
|
||||
})
|
||||
|
||||
const setOperator = (type, op) =>
|
||||
onSelectionsChange(prev => ({
|
||||
...prev,
|
||||
[type]: { ...prev[type], operator: op },
|
||||
}))
|
||||
|
||||
const toggleDueDate = op =>
|
||||
onSelectionsChange(prev => ({
|
||||
...prev,
|
||||
dueDate: { operator: prev.dueDate.operator === op ? null : op },
|
||||
}))
|
||||
|
||||
const setPointsOperator = op =>
|
||||
onSelectionsChange(prev => ({
|
||||
...prev,
|
||||
points: { ...prev.points, operator: op, active: true },
|
||||
}))
|
||||
|
||||
const setPointsValue = val =>
|
||||
onSelectionsChange(prev => ({
|
||||
...prev,
|
||||
points: { ...prev.points, value: val, active: val > 0 },
|
||||
}))
|
||||
|
||||
const chipRow = (type, options, getChipProps) => {
|
||||
const selected = selections[type].values || []
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{options.map(opt => {
|
||||
const isSelected = selected.includes(opt.value)
|
||||
const extra = getChipProps ? getChipProps(opt, isSelected) : {}
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (extra.color ?? 'primary') : 'neutral'}
|
||||
startDecorator={
|
||||
isSelected
|
||||
? <Check sx={{ fontSize: 14 }} />
|
||||
: (extra.startDecorator ?? null)
|
||||
}
|
||||
onClick={() => toggleValue(type, opt.value)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const personChipRow = type => {
|
||||
const selected = selections[type].values || []
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{members.map(m => {
|
||||
const isSelected = selected.includes(m.userId)
|
||||
return (
|
||||
<Chip
|
||||
key={m.userId}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? 'primary' : 'neutral'}
|
||||
startDecorator={
|
||||
isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (
|
||||
<Avatar
|
||||
src={m.image}
|
||||
alt={m.displayName}
|
||||
sx={{ '--Avatar-size': '20px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClick={() => toggleValue(type, m.userId)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{m.displayName || m.username}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Assignee */}
|
||||
{members.length > 0 && (
|
||||
<>
|
||||
<SectionHeader icon={<Person />} label='Assignee'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.assignee.operator}
|
||||
onChange={op => setOperator('assignee', op)}
|
||||
/>
|
||||
</SectionHeader>
|
||||
{personChipRow('assignee')}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Created By */}
|
||||
{members.length > 0 && (
|
||||
<>
|
||||
<SectionHeader icon={<Person />} label='Created By'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.createdBy.operator}
|
||||
onChange={op => setOperator('createdBy', op)}
|
||||
/>
|
||||
</SectionHeader>
|
||||
{personChipRow('createdBy')}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<SectionHeader icon={<TaskAlt />} label='Status'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.status.operator}
|
||||
onChange={op => setOperator('status', op)}
|
||||
/>
|
||||
</SectionHeader>
|
||||
{chipRow('status', CHORE_STATUSES)}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
|
||||
{/* Priority */}
|
||||
<SectionHeader icon={<PriorityHigh />} label='Priority'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.priority.operator}
|
||||
onChange={op => setOperator('priority', op)}
|
||||
/>
|
||||
</SectionHeader>
|
||||
{chipRow(
|
||||
'priority',
|
||||
Priorities.map(p => ({ value: p.value, label: p.name })),
|
||||
(opt, isSelected) => ({
|
||||
color: isSelected
|
||||
? (Priorities.find(p => p.value === opt.value)?.color || 'primary')
|
||||
: 'neutral',
|
||||
startDecorator: !isSelected
|
||||
? Priorities.find(p => p.value === opt.value)?.icon
|
||||
: null,
|
||||
}),
|
||||
)}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
|
||||
{/* Due Date */}
|
||||
<SectionHeader icon={<CalendarMonth />} label='Due Date' />
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{DUE_DATE_OPTIONS.map(opt => {
|
||||
const isSelected = selections.dueDate.operator === opt.value
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||
onClick={() => toggleDueDate(opt.value)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<>
|
||||
<SectionHeader icon={<Label />} label='Labels'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.label.operator}
|
||||
onChange={op => setOperator('label', op)}
|
||||
labels={['Has', "Doesn't Have"]}
|
||||
/>
|
||||
</SectionHeader>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{labels.map(lbl => {
|
||||
const isSelected = selections.label.values.includes(lbl.id)
|
||||
return (
|
||||
<Chip
|
||||
key={lbl.id}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: lbl.color || '#90a4ae',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
endDecorator={isSelected ? <Check sx={{ fontSize: 12 }} /> : null}
|
||||
onClick={() => toggleValue('label', lbl.id)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
...(isSelected && {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'primary.400',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{lbl.name}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Projects */}
|
||||
{projects.length > 0 && (
|
||||
<>
|
||||
<SectionHeader icon={<FolderOpen />} label='Projects'>
|
||||
<IncludeExcludeToggle
|
||||
value={selections.project.operator}
|
||||
onChange={op => setOperator('project', op)}
|
||||
/>
|
||||
</SectionHeader>
|
||||
{chipRow('project', [
|
||||
{ value: 'default', label: 'Default Project' },
|
||||
...projects
|
||||
.filter(p => p.id !== 'default')
|
||||
.map(p => ({ value: p.id, label: p.name })),
|
||||
])}
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Points */}
|
||||
<SectionHeader icon={<Stars />} label='Points' />
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
|
||||
>
|
||||
{POINTS_OPERATORS.map(op => (
|
||||
<Chip
|
||||
key={op.value}
|
||||
size='sm'
|
||||
variant={
|
||||
selections.points.operator === op.value && selections.points.active
|
||||
? 'solid'
|
||||
: 'soft'
|
||||
}
|
||||
color={
|
||||
selections.points.operator === op.value && selections.points.active
|
||||
? 'primary'
|
||||
: 'neutral'
|
||||
}
|
||||
onClick={() => setPointsOperator(op.value)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{op.label}
|
||||
</Chip>
|
||||
))}
|
||||
<Input
|
||||
type='number'
|
||||
size='sm'
|
||||
value={selections.points.value}
|
||||
onChange={e => setPointsValue(parseInt(e.target.value) || 0)}
|
||||
sx={{ width: 80 }}
|
||||
slotProps={{ input: { min: 0 } }}
|
||||
/>
|
||||
{selections.points.active && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={() =>
|
||||
onSelectionsChange(prev => ({
|
||||
...prev,
|
||||
points: { ...prev.points, active: false, value: 0 },
|
||||
}))
|
||||
}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
Clear
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterBuilderContent
|
||||
@@ -9,12 +9,10 @@ const MyChoreHeader = ({
|
||||
tempFilter,
|
||||
tempFilterMeta,
|
||||
}) => {
|
||||
if (
|
||||
!activeFilterId &&
|
||||
!tempFilter &&
|
||||
(!selectedProject || selectedProject.id === 'default')
|
||||
)
|
||||
return null
|
||||
const isVisible =
|
||||
!!activeFilterId ||
|
||||
!!tempFilter ||
|
||||
(!!selectedProject && selectedProject.id !== 'default')
|
||||
|
||||
const renderIcon = () => {
|
||||
if (tempFilter) {
|
||||
@@ -53,18 +51,41 @@ const MyChoreHeader = ({
|
||||
: activeFilter?.description || selectedProject?.description
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
{renderIcon()}
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography level='h3' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
|
||||
{name}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
{description}
|
||||
<Box
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
maxHeight: isVisible ? '120px' : '0',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'translateY(0)' : 'translateY(-8px)',
|
||||
transition:
|
||||
'max-height 0.3s ease-in-out, opacity 0.3s ease-in-out, transform 0.3s ease-in-out',
|
||||
marginBottom: isVisible ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{renderIcon()}
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
level='h3'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
maxHeight: description ? '40px' : '0',
|
||||
opacity: description ? 1 : 0,
|
||||
transition:
|
||||
'max-height 0.3s ease-in-out, opacity 0.3s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
{description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MarkChoreComplete,
|
||||
NudgeChore,
|
||||
RejectChore,
|
||||
SaveChore,
|
||||
SkipChore,
|
||||
UndoChoreAction,
|
||||
UpdateChoreAssignee,
|
||||
@@ -420,8 +421,8 @@ export const useChoreActions = ({
|
||||
c => c.id !== chore.id,
|
||||
)
|
||||
setChores(newChores)
|
||||
updateChoreInState(chore.id, 'deleted')
|
||||
setFilteredChores(newFilteredChores)
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showSuccess({
|
||||
title: 'Task Deleted',
|
||||
message: 'The task has been deleted successfully.',
|
||||
@@ -471,7 +472,7 @@ export const useChoreActions = ({
|
||||
await new Promise((resolve, reject) => {
|
||||
archiveChore.mutate(chore.id, {
|
||||
onSuccess: data => {
|
||||
updateChoreInState(data, 'archive')
|
||||
updateChoreInState(chore, 'archive')
|
||||
resolve(data)
|
||||
},
|
||||
onError: async error => {
|
||||
@@ -664,6 +665,28 @@ export const useChoreActions = ({
|
||||
}
|
||||
break
|
||||
|
||||
case 'moveToProject': {
|
||||
const project = extraData?.project
|
||||
const projectId = project?.id === null ? null : project?.id
|
||||
const updatedChore = { ...chore, projectId }
|
||||
try {
|
||||
const response = await SaveChore(updatedChore)
|
||||
if (response.ok) {
|
||||
updateChoreInState(updatedChore, 'moved-to-project')
|
||||
showSuccess({
|
||||
title: 'Task Moved',
|
||||
message: `Task moved to ${project?.name || 'Default Project'}.`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to move task',
|
||||
message: error?.message || 'Unable to move task to project',
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'completeWithNote':
|
||||
case 'completeWithPastDate':
|
||||
case 'changeAssignee':
|
||||
|
||||
@@ -90,6 +90,8 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
|
||||
}, [chores, activeFilter, tempFilter, context])
|
||||
|
||||
const applyCustomFilter = useCallback(filterId => {
|
||||
setTempFilter(null)
|
||||
setTempFilterMeta(null)
|
||||
setActiveFilterId(filterId)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,52 +1,226 @@
|
||||
import { HomeRounded, Login } from '@mui/icons-material'
|
||||
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Logo from '../Logo' // Adjust the import path as necessary
|
||||
import {
|
||||
BugReportRounded,
|
||||
CloudOffRounded,
|
||||
ContentCopyRounded,
|
||||
ErrorRounded,
|
||||
ExpandMoreRounded,
|
||||
HomeRounded,
|
||||
LockRounded,
|
||||
RefreshRounded,
|
||||
SearchOffRounded,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { Link, useRouteError } from 'react-router-dom'
|
||||
|
||||
const getErrorKind = error => {
|
||||
if (!error)
|
||||
return { label: 'Unknown Error', color: 'danger', Icon: ErrorRounded }
|
||||
const status = error?.status ?? error?.response?.status
|
||||
if (status === 404)
|
||||
return {
|
||||
label: '404 · Not Found',
|
||||
color: 'warning',
|
||||
Icon: SearchOffRounded,
|
||||
}
|
||||
if (status === 401 || status === 403)
|
||||
return {
|
||||
label: `${status} · Unauthorized`,
|
||||
color: 'warning',
|
||||
Icon: LockRounded,
|
||||
}
|
||||
if (status >= 500)
|
||||
return {
|
||||
label: `${status} · Server Error`,
|
||||
color: 'danger',
|
||||
Icon: CloudOffRounded,
|
||||
}
|
||||
if (error?.name === 'TypeError')
|
||||
return { label: 'Runtime Error', color: 'danger', Icon: BugReportRounded }
|
||||
if (error?.name === 'SyntaxError')
|
||||
return { label: 'Syntax Error', color: 'danger', Icon: BugReportRounded }
|
||||
return { label: 'Unexpected Error', color: 'danger', Icon: ErrorRounded }
|
||||
}
|
||||
|
||||
const safeMessage = error => {
|
||||
const msg = error?.message ?? error?.statusText
|
||||
if (!msg || msg === '[object Object]') return null
|
||||
return msg
|
||||
}
|
||||
|
||||
const buildErrorText = (error, url) => {
|
||||
const lines = [
|
||||
`URL: ${url}`,
|
||||
`Time: ${new Date().toISOString()}`,
|
||||
`Error: ${safeMessage(error) ?? String(error)}`,
|
||||
]
|
||||
if (error?.stack) lines.push(`\nStack:\n${error.stack}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const Error = () => {
|
||||
const error = useRouteError()
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const { color, Icon } = getErrorKind(error)
|
||||
const message = safeMessage(error)
|
||||
const url = window.location.href
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(buildErrorText(error, url)).then(() => {
|
||||
setCopied(true)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className='flex h-full items-center justify-center'>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
minHeight: '100dvh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
px: 3,
|
||||
py: 6,
|
||||
maxWidth: 440,
|
||||
mx: 'auto',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Decorative dots */}
|
||||
<Box
|
||||
className='flex flex-col items-center justify-center'
|
||||
sx={{
|
||||
minHeight: '80vh',
|
||||
position: 'absolute',
|
||||
top: '14%',
|
||||
left: '6%',
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
bgcolor: `${color}.100`,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '22%',
|
||||
right: '8%',
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: '50%',
|
||||
bgcolor: `${color}.200`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: '28%',
|
||||
right: '6%',
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: '50%',
|
||||
bgcolor: `${color}.100`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Icon with concentric rings */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mb: 4,
|
||||
}}
|
||||
>
|
||||
<CircularProgress
|
||||
value={100}
|
||||
color='danger' // Set the color to 'error' for danger color
|
||||
sx={{ '--CircularProgress-size': '200px' }}
|
||||
>
|
||||
<Logo />
|
||||
</CircularProgress>
|
||||
<Box
|
||||
className='flex items-center gap-2'
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: 24,
|
||||
mt: 2,
|
||||
width: 172,
|
||||
height: 172,
|
||||
borderRadius: '50%',
|
||||
border: '1.5px solid',
|
||||
borderColor: `${color}.100`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
Ops, something went wrong
|
||||
</Box>
|
||||
<Typography level='body-md' fontWeight={500} textAlign={'center'}>
|
||||
if you think this is a mistake, please contact us or{' '}
|
||||
<a
|
||||
href='https://github.com/donetick/donetick/issues/new'
|
||||
style={{
|
||||
textDecoration: 'underline',
|
||||
<Box
|
||||
sx={{
|
||||
width: 128,
|
||||
height: 128,
|
||||
borderRadius: '50%',
|
||||
border: '1.5px solid',
|
||||
borderColor: `${color}.200`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
open issue here
|
||||
</a>{' '}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: 84,
|
||||
height: 84,
|
||||
borderRadius: '50%',
|
||||
bgcolor: `${color}.50`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Icon sx={{ fontSize: 42, color: `${color}.500` }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Title */}
|
||||
<Typography
|
||||
level='h3'
|
||||
fontWeight={700}
|
||||
textAlign='center'
|
||||
sx={{ mb: 1.5 }}
|
||||
>
|
||||
Something went wrong
|
||||
</Typography>
|
||||
|
||||
{/* Error message */}
|
||||
<Typography
|
||||
level='body-sm'
|
||||
textAlign='center'
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
mb: 4,
|
||||
maxWidth: 320,
|
||||
minHeight: '2.5em',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{message ??
|
||||
'An unexpected error occurred. Try reloading — it usually fixes it.'}
|
||||
</Typography>
|
||||
|
||||
{/* Primary CTA */}
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
startDecorator={<RefreshRounded />}
|
||||
onClick={() => window.location.reload()}
|
||||
sx={{ width: '100%', mb: 2 }}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
|
||||
{/* Secondary actions */}
|
||||
<Box sx={{ display: 'flex', gap: 3, mb: 5 }}>
|
||||
<Button
|
||||
component={Link}
|
||||
to='/chores'
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
sx={{ mt: 4 }}
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
startDecorator={<HomeRounded />}
|
||||
>
|
||||
@@ -55,16 +229,108 @@ const Error = () => {
|
||||
<Button
|
||||
component={Link}
|
||||
to='/login'
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
sx={{ mt: 1 }}
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
startDecorator={<Login />}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
{/* Report hint + collapsible details */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
textAlign='center'
|
||||
sx={{ color: 'text.tertiary', mb: 1.5 }}
|
||||
>
|
||||
If this keeps happening,{' '}
|
||||
<a
|
||||
href='https://github.com/donetick/donetick/issues/new'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
open an issue
|
||||
</a>{' '}
|
||||
and include the error details below.
|
||||
</Typography>
|
||||
|
||||
{(error?.stack || message) && (
|
||||
<>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={() => setShowDetails(v => !v)}
|
||||
endDecorator={
|
||||
<ExpandMoreRounded
|
||||
sx={{
|
||||
transition: 'transform 0.2s',
|
||||
transform: showDetails ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
{showDetails ? 'Hide' : 'Show'} error details
|
||||
</Button>
|
||||
|
||||
{showDetails && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
bgcolor: 'background.level2',
|
||||
borderRadius: 'sm',
|
||||
p: 2,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={handleCopy}
|
||||
sx={{ position: 'absolute', top: 8, right: 8 }}
|
||||
title='Copy to clipboard'
|
||||
>
|
||||
<ContentCopyRounded fontSize='small' />
|
||||
</IconButton>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
pr: 4,
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{buildErrorText(error, url)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Snackbar
|
||||
open={copied}
|
||||
autoHideDuration={2500}
|
||||
onClose={() => setCopied(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
size='sm'
|
||||
>
|
||||
Error details copied to clipboard
|
||||
</Snackbar>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,21 @@ import {
|
||||
import '@meauxt/react-swipeable-list/dist/styles.css'
|
||||
import {
|
||||
Analytics,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
Checklist,
|
||||
EventBusy,
|
||||
EventNote,
|
||||
FilterList,
|
||||
Group,
|
||||
History,
|
||||
HourglassEmpty,
|
||||
Person,
|
||||
Redo,
|
||||
RunningWithErrors,
|
||||
Schedule,
|
||||
Star,
|
||||
ThumbDown,
|
||||
Timelapse,
|
||||
TrendingUp,
|
||||
} from '@mui/icons-material'
|
||||
@@ -22,8 +32,10 @@ import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||
import {
|
||||
useChoreHistory,
|
||||
@@ -35,6 +47,7 @@ import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ChoreHistoryStatus } from '../../utils/Chores'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import EditHistoryModal from '../Modals/EditHistoryModal'
|
||||
import HistoryDetailModal from '../Modals/HistoryDetailModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import HistoryCard from './HistoryCard'
|
||||
@@ -49,7 +62,8 @@ const ChoreHistory = () => {
|
||||
const { fmt } = useLocalization()
|
||||
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||
const { showSuccess } = useNotification()
|
||||
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
|
||||
const { showSuccess, showError } = useNotification()
|
||||
// React Query hooks
|
||||
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
|
||||
const { data: circleMembersData } = useCircleMembers()
|
||||
@@ -77,6 +91,61 @@ const ChoreHistory = () => {
|
||||
}, {})
|
||||
}, [pendingCmds])
|
||||
|
||||
const filterDefs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
type: 'multi-select',
|
||||
icon: <FilterList />,
|
||||
options: [
|
||||
{ value: ChoreHistoryStatus.COMPLETED, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
|
||||
{ value: ChoreHistoryStatus.SKIPPED, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
|
||||
{ value: ChoreHistoryStatus.PENDING_APPROVAL, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
|
||||
{ value: ChoreHistoryStatus.REJECTED, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
|
||||
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
|
||||
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
|
||||
],
|
||||
filterFn: (item, values) => values.includes(item.status),
|
||||
},
|
||||
{
|
||||
id: 'hasNotes',
|
||||
label: 'Has Notes',
|
||||
type: 'boolean',
|
||||
icon: <EventNote />,
|
||||
filterFn: item => !!item.notes,
|
||||
},
|
||||
{
|
||||
id: 'completedBy',
|
||||
label: 'Completed By',
|
||||
type: 'multi-select',
|
||||
icon: <Person />,
|
||||
options: performers.map(p => ({
|
||||
value: p.userId,
|
||||
label: p.displayName,
|
||||
avatar: p.image,
|
||||
})),
|
||||
filterFn: (item, values) => values.includes(item.completedBy),
|
||||
},
|
||||
{
|
||||
id: 'dateRange',
|
||||
label: 'Completed At',
|
||||
type: 'date-range',
|
||||
icon: <CalendarMonth />,
|
||||
filterFn: (item, value) => {
|
||||
const performed = new Date(item.performedAt || item.updatedAt)
|
||||
if (value.from && performed < new Date(value.from)) return false
|
||||
if (value.to && performed > new Date(value.to)) return false
|
||||
return true
|
||||
},
|
||||
},
|
||||
],
|
||||
[performers],
|
||||
)
|
||||
|
||||
const { filteredData: filteredHistory, activeFilters, setFilter, clearAll, activeFilterCount } =
|
||||
useFilter(choreHistory, filterDefs)
|
||||
|
||||
const handleDelete = historyEntry => {
|
||||
showConfirmation(
|
||||
`Are you sure you want to delete this history record?`,
|
||||
@@ -224,9 +293,10 @@ const ChoreHistory = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth='md'>
|
||||
<Container maxWidth='md' sx={{ px: 0 }}>
|
||||
{/* Enhanced Header Section */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Box sx={{ gap: 2, p: 2 }}>
|
||||
{/* <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}> */}
|
||||
{/* Statistics Cards Grid - Compact Design */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<History sx={{ fontSize: '1.5rem' }} />
|
||||
@@ -304,7 +374,9 @@ const ChoreHistory = () => {
|
||||
</Box>
|
||||
|
||||
{/* History Section Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
|
||||
|
||||
<Analytics sx={{ fontSize: '1.5rem' }} />
|
||||
<Typography
|
||||
level='title-md'
|
||||
@@ -313,14 +385,50 @@ const ChoreHistory = () => {
|
||||
Task Activity
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 2 }}>
|
||||
<FilterBar
|
||||
filterDefs={filterDefs}
|
||||
activeFilters={activeFilters}
|
||||
onSetFilter={setFilter}
|
||||
onClearAll={clearAll}
|
||||
resultCount={filteredHistory.length}
|
||||
totalCount={choreHistory.length}
|
||||
/>
|
||||
</Box>
|
||||
{filteredHistory.length === 0 && activeFilterCount > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 6,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<FilterList sx={{ fontSize: '3rem', color: 'text.tertiary' }} />
|
||||
<Typography level='title-md' sx={{ color: 'text.secondary' }}>
|
||||
No results match your filters
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.tertiary' }}>
|
||||
Try adjusting or clearing the active filters.
|
||||
</Typography>
|
||||
<Button variant='soft' size='sm' onClick={clearAll} sx={{ mt: 0.5 }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{filteredHistory.length > 0 && (
|
||||
<Sheet
|
||||
variant='plain'
|
||||
sx={{ borderRadius: 'sm', boxShadow: 'md', overflow: 'hidden' }}
|
||||
sx={{ borderRadius: 'sm', overflow: 'hidden' }}
|
||||
>
|
||||
{/* Chore History List (Updated Style) */}
|
||||
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{choreHistory.map((historyEntry, index) => (
|
||||
{filteredHistory.map((historyEntry, index) => (
|
||||
<SwipeableListItem
|
||||
key={historyEntry.id || index}
|
||||
swipeActionOpen={
|
||||
@@ -385,6 +493,19 @@ const ChoreHistory = () => {
|
||||
performers={performers}
|
||||
allHistory={choreHistory}
|
||||
index={index}
|
||||
onViewDetails={() => {
|
||||
setDetailModalConfig({
|
||||
isOpen: true,
|
||||
entry: historyEntry,
|
||||
performers,
|
||||
onClose: () => setDetailModalConfig({ isOpen: false }),
|
||||
onEdit: record => {
|
||||
setDetailModalConfig({ isOpen: false })
|
||||
setEditHistory(record)
|
||||
setIsEditModalOpen(true)
|
||||
},
|
||||
})
|
||||
}}
|
||||
pendingCommands={pendingByHistoryId[historyEntry.id] || []}
|
||||
onViewNote={notes => {
|
||||
setNoteViewerConfig({
|
||||
@@ -407,6 +528,7 @@ const ChoreHistory = () => {
|
||||
))}
|
||||
</SwipeableList>
|
||||
</Sheet>
|
||||
)}
|
||||
<EditHistoryModal
|
||||
config={{
|
||||
isOpen: isEditModalOpen,
|
||||
@@ -481,7 +603,8 @@ const ChoreHistory = () => {
|
||||
/>
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
<NoteViewerModal config={noteViewerConfig} />
|
||||
</Container>
|
||||
<HistoryDetailModal config={detailModalConfig} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,99 +1,47 @@
|
||||
import {
|
||||
AccessTime,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
EventNote,
|
||||
HourglassEmpty,
|
||||
MoreVert,
|
||||
Person,
|
||||
Redo,
|
||||
RunningWithErrors,
|
||||
Schedule,
|
||||
ThumbDown,
|
||||
Timelapse,
|
||||
Toll,
|
||||
} from '@mui/icons-material'
|
||||
import { Avatar, Box, Chip, Grid, IconButton, Typography } from '@mui/joy'
|
||||
import { Avatar, Box, Card, Chip, IconButton, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
|
||||
const getCompletedChip = historyEntry => {
|
||||
if (
|
||||
historyEntry.status === 0 ||
|
||||
historyEntry.status === 5 ||
|
||||
historyEntry.status === 6
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!historyEntry.dueDate) {
|
||||
return null
|
||||
// <Chip
|
||||
// size='sm'
|
||||
// variant='soft'
|
||||
// color='neutral'
|
||||
// startDecorator={<CalendarViewDay />}
|
||||
// >
|
||||
// No Due Date
|
||||
// </Chip>
|
||||
}
|
||||
|
||||
const performedAt = moment(historyEntry.performedAt)
|
||||
const dueDate = moment(historyEntry.dueDate)
|
||||
// TODO: make this a config at some point
|
||||
const gracePeriod = 6 * 60 * 60 * 1000 // 6 hours in milliseconds
|
||||
|
||||
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }}
|
||||
startDecorator={<Check />}
|
||||
>
|
||||
On Time
|
||||
</Chip>
|
||||
)
|
||||
} else if (performedAt.isBefore(dueDate)) {
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }}
|
||||
startDecorator={<Check />}
|
||||
>
|
||||
Early
|
||||
</Chip>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}
|
||||
startDecorator={<Timelapse />}
|
||||
>
|
||||
Late
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = seconds => {
|
||||
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) {
|
||||
return null
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) return null
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const stripHtmlTags = html => {
|
||||
if (!html) return ''
|
||||
if (typeof document === 'undefined') {
|
||||
return String(html).replace(/<[^>]*>/g, '')
|
||||
}
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = html
|
||||
return div.textContent || div.innerText || ''
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
|
||||
1: { label: 'Completed', color: 'success', icon: <Check /> },
|
||||
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
|
||||
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
|
||||
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact HistoryCard component - content only
|
||||
*/
|
||||
const HistoryCard = ({
|
||||
allHistory,
|
||||
performers,
|
||||
@@ -102,235 +50,137 @@ const HistoryCard = ({
|
||||
pendingCommands,
|
||||
onToggleActions,
|
||||
onViewNote,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
const { fmt } = useLocalization()
|
||||
const performer = performers.find(p => p.userId === historyEntry.completedBy)
|
||||
const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo)
|
||||
const config = statusConfig[historyEntry.status] ?? statusConfig[1]
|
||||
const displayLabel =
|
||||
historyEntry.status === 6 && !historyEntry.dueDate ? 'Scheduled' : config.label
|
||||
const actionDate = historyEntry.performedAt || historyEntry.updatedAt
|
||||
|
||||
const formatTimeDifference = (startDate, endDate) => {
|
||||
const diffInMinutes = moment(startDate).diff(endDate, 'minutes')
|
||||
let timeValue = diffInMinutes
|
||||
let unit = 'minute'
|
||||
const getTimingLine = () => {
|
||||
const { status, performedAt, dueDate } = historyEntry
|
||||
if (!dueDate) return null
|
||||
|
||||
if (diffInMinutes >= 60) {
|
||||
const diffInHours = moment(startDate).diff(endDate, 'hours')
|
||||
timeValue = diffInHours
|
||||
unit = 'hour'
|
||||
|
||||
if (diffInHours >= 24) {
|
||||
const diffInDays = moment(startDate).diff(endDate, 'days')
|
||||
timeValue = diffInDays
|
||||
unit = 'day'
|
||||
}
|
||||
if (status === 6) {
|
||||
return `Was due ${moment(dueDate).format('MMM D')}`
|
||||
}
|
||||
|
||||
return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}`
|
||||
if (status === 5) {
|
||||
return `Was due ${moment(dueDate).format('MMM D')}`
|
||||
}
|
||||
if ((status === 1 || status === 2 || status === 0) && performedAt) {
|
||||
const diffHours = moment(performedAt).diff(dueDate, 'hours')
|
||||
const abs = Math.abs(diffHours)
|
||||
if (abs <= 6) return null // chip already says "On Time"
|
||||
if (diffHours < 0) return abs >= 48 ? `${Math.floor(abs / 24)}d before due date` : `${abs}h before due date`
|
||||
return abs >= 48 ? `${Math.floor(abs / 24)}d after due date` : `${abs}h after due date`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getStatusAvatar = () => {
|
||||
const statusMap = {
|
||||
0: { icon: <AccessTime />, color: 'primary' }, // Started
|
||||
1: { icon: <Check />, color: 'success' }, // Completed
|
||||
2: { icon: <Redo />, color: 'warning' }, // Skipped
|
||||
3: { icon: <HourglassEmpty />, color: 'neutral' }, // Pending Approval
|
||||
4: { icon: <ThumbDown />, color: 'danger' }, // Rejected
|
||||
5: { icon: <RunningWithErrors />, color: 'danger' }, // Missed
|
||||
6: { icon: <Schedule />, color: 'warning' }, // Rescheduled
|
||||
}
|
||||
const timingLine = getTimingLine()
|
||||
const noteLabel = historyEntry.status === 2 || historyEntry.status === 4 ? 'Reason' : 'Note'
|
||||
const plainTextNotes = historyEntry.notes ? stripHtmlTags(historyEntry.notes) : ''
|
||||
|
||||
const config = statusMap[historyEntry.status] || statusMap[1]
|
||||
return (
|
||||
<Avatar
|
||||
size='sm'
|
||||
color={config.color}
|
||||
variant='soft'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
'& svg': { fontSize: '14px' },
|
||||
}}
|
||||
>
|
||||
{config.icon}
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
const metaTextParts = [
|
||||
fmt.dateTime(actionDate),
|
||||
historyEntry.completedBy !== historyEntry.assignedTo && assignedTo
|
||||
? `Assigned to ${assignedTo.displayName}`
|
||||
: null,
|
||||
historyEntry?.duration > 0 ? `⏱ ${formatTime(historyEntry.duration)}` : null,
|
||||
historyEntry?.points > 0 ? `★ ${historyEntry.points} pt${historyEntry.points > 1 ? 's' : ''}` : null,
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onViewDetails?.()}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 64,
|
||||
minWidth: '100%',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'background.body',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderLeft: '3px solid',
|
||||
borderLeftColor: `${config.color}.400`,
|
||||
cursor: onViewDetails ? 'pointer' : 'default',
|
||||
'&:hover': onViewDetails ? { bgcolor: 'background.level1' } : {},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Grid container spacing={1} alignItems='center'>
|
||||
{/* First Row/Column: Status and Time Info */}
|
||||
<Grid xs={12} sm={8}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
<Box sx={{ flex: 1, minWidth: 0, px: 2, py: 1.5 }}>
|
||||
{/* Status + timing chip */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Avatar
|
||||
size='sm'
|
||||
color={config.color}
|
||||
variant='soft'
|
||||
sx={{ width: 20, height: 20, '& svg': { fontSize: '11px' } }}
|
||||
>
|
||||
{getStatusAvatar()}
|
||||
{config.icon}
|
||||
</Avatar>
|
||||
<Typography level='title-sm' fontWeight='lg' sx={{ color: `${config.color}.plainColor` }}>
|
||||
{displayLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
{historyEntry.status === 0
|
||||
? 'In Progress'
|
||||
: historyEntry.status === 1
|
||||
? 'Completed'
|
||||
: historyEntry.status === 2
|
||||
? 'Skipped'
|
||||
: historyEntry.status === 3
|
||||
? 'Pending Approval'
|
||||
: historyEntry.status === 4
|
||||
? 'Rejected'
|
||||
: historyEntry.status === 5
|
||||
? 'Missed'
|
||||
: historyEntry.status === 6
|
||||
? 'Rescheduled'
|
||||
: 'Completed'}
|
||||
</Typography>
|
||||
{/* Timing relationship line */}
|
||||
{timingLine && (
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.25 }}>
|
||||
{timingLine}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Chip size='sm' startDecorator={<EventNote />}>
|
||||
{fmt.dateTime(
|
||||
historyEntry.performedAt || historyEntry.updatedAt,
|
||||
)}
|
||||
</Chip>
|
||||
{/* Notes inline */}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{getCompletedChip(historyEntry)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
{plainTextNotes && (
|
||||
<Card
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{ mt: 0.5, whiteSpace: 'pre-wrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.secondary', fontStyle: 'italic', mb: 0.25, cursor: 'pointer' }}
|
||||
onClick={e => { e.stopPropagation(); onViewNote?.(historyEntry.notes) }}
|
||||
>
|
||||
{plainTextNotes.length > 80 ? `${plainTextNotes.slice(0, 80)}…` : plainTextNotes}
|
||||
</Typography>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Second Row/Column: Completion Status (right side on desktop) */}
|
||||
<Grid xs={12} sm={4}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
{/* Metadata strip: performer chip + date + extras */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
|
||||
{performer && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
<Avatar src={performer.image} alt={performer.displayName} sx={{ width: 14, height: 14 }} />
|
||||
}
|
||||
>
|
||||
{historyEntry.dueDate && (
|
||||
<Chip size='sm' startDecorator={<CalendarMonth />}>
|
||||
{fmt.dateTime(historyEntry.dueDate)}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Third Row: Performer and Assignment Info */}
|
||||
<Grid xs={12}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
{performer && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
startDecorator={
|
||||
<Avatar
|
||||
src={performer?.image}
|
||||
alt={performer?.displayName}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{performer?.displayName || 'Unknown'}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{historyEntry.completedBy !== historyEntry.assignedTo &&
|
||||
assignedTo && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Person />}
|
||||
>
|
||||
Assigned to {assignedTo.displayName}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{historyEntry.notes && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<EventNote />}
|
||||
sx={{
|
||||
maxWidth: '120px',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onViewNote?.(historyEntry.notes)
|
||||
}}
|
||||
>
|
||||
Note
|
||||
</Chip>
|
||||
)}
|
||||
{/* add a duration chip if we have duration */}
|
||||
{historyEntry?.duration > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='primary'
|
||||
startDecorator={<AccessTime />}
|
||||
>
|
||||
{formatTime(historyEntry.duration)}
|
||||
</Chip>
|
||||
)}
|
||||
{historyEntry?.points > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
startDecorator={<Toll />}
|
||||
>
|
||||
{historyEntry.points} pt
|
||||
{historyEntry.points > 1 ? 's' : ''}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{performer.displayName}
|
||||
</Chip>
|
||||
)}
|
||||
{metaTextParts.length > 0 && (
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
{metaTextParts.join(' · ')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', pr: 0.5 }} onClick={e => e.stopPropagation()}>
|
||||
{onToggleActions && (
|
||||
<IconButton
|
||||
color='neutral'
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onToggleActions()
|
||||
}}
|
||||
onClick={e => { e.stopPropagation(); onToggleActions() }}
|
||||
>
|
||||
<MoreVert sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Card, Grid, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useState } from 'react'
|
||||
import ChoreCard from '../Chores/ChoreCard'
|
||||
|
||||
const DemoMyChore = () => {
|
||||
const [selectedCalendarDate, setSelectedCalendarDate] = useState(null)
|
||||
|
||||
const cards = [
|
||||
{
|
||||
id: 12,
|
||||
name: '♻️ Take out recycle ',
|
||||
frequencyType: 'days_of_the_week',
|
||||
frequency: 1,
|
||||
priority: 1,
|
||||
frequencyMetadata:
|
||||
'{"days":["thursday"],"time":"2024-07-07T22:00:00-04:00"}',
|
||||
nextDueDate: moment().add(1, 'days').hour(8).minute(0).toISOString(),
|
||||
@@ -96,6 +100,17 @@ const DemoMyChore = () => {
|
||||
]
|
||||
|
||||
const users = [{ displayName: 'Me', id: 1, userId: 1 }]
|
||||
|
||||
// Helper function to get chores for a specific date
|
||||
const getChoresForDate = date => {
|
||||
return cards.filter(chore => {
|
||||
if (!chore.nextDueDate) return false
|
||||
const choreDate = new Date(chore.nextDueDate).toLocaleDateString()
|
||||
const selectedDate = date.toLocaleDateString()
|
||||
return choreDate === selectedDate
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid item xs={12} sm={5} data-aos-first-tasks-list>
|
||||
|
||||
@@ -17,6 +17,10 @@ import TabletInstallationSection from './TabletInstallationSection'
|
||||
const Landing = () => {
|
||||
const Navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
// if the host is https://app.donetick.com/ then redirect to https://app.donetick.com/my/chores:
|
||||
if (window.location.host === 'app.donetick.com') {
|
||||
Navigate('/chores')
|
||||
}
|
||||
AOS.init({
|
||||
once: false, // whether animation should happen only once - while scrolling down
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ipad_screenshot from '@/assets/ipad_dashbard_calendar.png'
|
||||
import { Box, Container, Typography } from '@mui/joy'
|
||||
|
||||
const TabletInstallationSection = () => {
|
||||
return (
|
||||
<Container maxWidth='xl' sx={{ py: { xs: 6, sm: 8, md: 12 } }}>
|
||||
|
||||
230
src/views/Modals/HistoryDetailModal.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
AccessTime,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
Edit,
|
||||
HourglassEmpty,
|
||||
OpenInNew,
|
||||
Person,
|
||||
Redo,
|
||||
RunningWithErrors,
|
||||
Schedule,
|
||||
ThumbDown,
|
||||
Update,
|
||||
} from '@mui/icons-material'
|
||||
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
|
||||
1: { label: 'Completed', color: 'success', icon: <Check /> },
|
||||
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
|
||||
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
|
||||
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
|
||||
}
|
||||
|
||||
const DetailRow = ({ icon, label, value, children }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, py: 0.75 }}>
|
||||
<Box sx={{ color: 'text.tertiary', mt: 0.25, flexShrink: 0, display: 'flex' }}>{icon}</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.15 }}>{label}</Typography>
|
||||
{children ?? (
|
||||
<Typography level='body-sm' sx={{ color: 'text.primary', fontWeight: 'md' }}>{value}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const TimingBadge = ({ historyEntry }) => {
|
||||
if (!historyEntry.dueDate || !historyEntry.performedAt) return null
|
||||
if ([0, 5, 6].includes(historyEntry.status)) return null
|
||||
|
||||
const performedAt = moment(historyEntry.performedAt)
|
||||
const dueDate = moment(historyEntry.dueDate)
|
||||
const diffHours = performedAt.diff(dueDate, 'hours')
|
||||
const gracePeriod = 6 * 60 * 60 * 1000
|
||||
|
||||
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
|
||||
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }} startDecorator={<Check />}>On Time</Chip>
|
||||
} else if (performedAt.isBefore(dueDate)) {
|
||||
const abs = Math.abs(diffHours)
|
||||
const label = abs >= 48 ? `${Math.floor(abs / 24)}d early` : `${abs}h early`
|
||||
return <Chip size='sm' variant='soft' sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }} startDecorator={<Check />}>{label}</Chip>
|
||||
} else {
|
||||
const abs = Math.abs(diffHours)
|
||||
const label = abs >= 48 ? `${Math.floor(abs / 24)}d late` : `${abs}h late`
|
||||
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}>{label}</Chip>
|
||||
}
|
||||
}
|
||||
|
||||
function HistoryDetailModal({ config }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { fmt } = useLocalization()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const entry = config?.entry
|
||||
const performers = config?.performers ?? []
|
||||
|
||||
if (!entry) return null
|
||||
|
||||
const statusCfg = STATUS_CONFIG[entry.status] ?? STATUS_CONFIG[1]
|
||||
const isFirstSchedule = entry.status === 6 && !entry.dueDate
|
||||
const statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
|
||||
const performer = performers.find(p => p.userId === entry.completedBy)
|
||||
const assignedTo = performers.find(p => p.userId === entry.assignedTo)
|
||||
const isDifferentAssignee = entry.assignedTo && entry.completedBy !== entry.assignedTo
|
||||
|
||||
// updatedAt is only meaningful if it differs from performedAt by more than a minute
|
||||
const showUpdatedAt =
|
||||
entry.updatedAt &&
|
||||
entry.performedAt &&
|
||||
Math.abs(moment(entry.updatedAt).diff(entry.performedAt, 'minutes')) > 1
|
||||
|
||||
const formatDuration = seconds => {
|
||||
if (!seconds || seconds <= 0) return null
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={config?.isOpen}
|
||||
onClose={config?.onClose}
|
||||
title='Activity Detail'
|
||||
>
|
||||
{/* Status header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar size='sm' color={statusCfg.color} variant='soft'>
|
||||
{statusCfg.icon}
|
||||
</Avatar>
|
||||
<Typography level='title-md' fontWeight='lg' sx={{ color: `${statusCfg.color}.plainColor` }}>
|
||||
{statusLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TimingBadge historyEntry={entry} />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
|
||||
<Stack spacing={0}>
|
||||
{/* Who performed it */}
|
||||
{performer && (
|
||||
<DetailRow icon={<Check sx={{ fontSize: 16 }} />} label='Performed by'>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Avatar src={performer.image} alt={performer.displayName} size='sm' sx={{ width: 20, height: 20 }} />
|
||||
<Typography level='body-sm' fontWeight='md'>{performer.displayName}</Typography>
|
||||
</Box>
|
||||
</DetailRow>
|
||||
)}
|
||||
|
||||
{/* Assigned to (only if different) */}
|
||||
{isDifferentAssignee && assignedTo && (
|
||||
<DetailRow icon={<Person sx={{ fontSize: 16 }} />} label='Assigned to' value={assignedTo.displayName} />
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Performed at */}
|
||||
{entry.performedAt && (
|
||||
<DetailRow
|
||||
icon={<AccessTime sx={{ fontSize: 16 }} />}
|
||||
label={isFirstSchedule ? 'Scheduled on' : entry.status === 6 ? 'Rescheduled on' : entry.status === 2 ? 'Skipped on' : 'Completed on'}
|
||||
value={fmt.dateTime(entry.performedAt)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Due date */}
|
||||
{entry.dueDate && (
|
||||
<DetailRow
|
||||
icon={<CalendarMonth sx={{ fontSize: 16 }} />}
|
||||
label={entry.status === 6 ? 'Previous due date' : entry.status === 5 ? 'Was due' : 'Due date'}
|
||||
value={fmt.dateTime(entry.dueDate)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Last updated (only if meaningfully different from performedAt) */}
|
||||
{showUpdatedAt && (
|
||||
<DetailRow
|
||||
icon={<Update sx={{ fontSize: 16 }} />}
|
||||
label='Last updated'
|
||||
value={fmt.dateTime(entry.updatedAt)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
{entry.duration > 0 && (
|
||||
<DetailRow
|
||||
icon={<Schedule sx={{ fontSize: 16 }} />}
|
||||
label='Duration'
|
||||
value={formatDuration(entry.duration)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Points */}
|
||||
{entry.points > 0 && (
|
||||
<DetailRow
|
||||
icon={<Typography sx={{ fontSize: 14 }}>★</Typography>}
|
||||
label='Points earned'
|
||||
value={`${entry.points} pt${entry.points > 1 ? 's' : ''}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{entry.notes && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.5 }}>
|
||||
{entry.status === 2 || entry.status === 4 ? 'Reason' : 'Notes'}
|
||||
</Typography>
|
||||
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
|
||||
<RichTextEditor value={entry.notes || ''} isEditable={false} />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Action buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 2, justifyContent: 'flex-end' }}>
|
||||
{entry.choreId && (
|
||||
<Button
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={<OpenInNew sx={{ fontSize: 16 }} />}
|
||||
onClick={() => {
|
||||
config?.onClose?.()
|
||||
navigate(`/chores/${entry.choreId}`)
|
||||
}}
|
||||
>
|
||||
Open Task
|
||||
</Button>
|
||||
)}
|
||||
{config?.onEdit && (
|
||||
<Button
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='md'
|
||||
startDecorator={<Edit sx={{ fontSize: 16 }} />}
|
||||
onClick={() => config.onEdit(entry)}
|
||||
>
|
||||
Edit Entry
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default HistoryDetailModal
|
||||
@@ -1,22 +1,21 @@
|
||||
import { Add, Delete } from '@mui/icons-material'
|
||||
import { Save } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
IconButton,
|
||||
Divider,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
Option,
|
||||
Select,
|
||||
Textarea,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import BottomSheetModal from '../../../components/common/BottomSheetModal'
|
||||
import FilterBuilderContent, {
|
||||
conditionsToSelections,
|
||||
defaultSelections,
|
||||
selectionsToConditions,
|
||||
} from '../../Chores/components/FilterBuilderContent'
|
||||
import { FILTER_COLORS } from '../../../utils/Colors'
|
||||
import { applyFilter } from '../../../utils/FilterEngine'
|
||||
import Priorities from '../../../utils/Priorities'
|
||||
import { useFilters } from '../../Filters/FilterQueries'
|
||||
|
||||
const AdvancedFilterBuilder = ({
|
||||
@@ -30,527 +29,149 @@ const AdvancedFilterBuilder = ({
|
||||
userProfile = null,
|
||||
editingFilter = null,
|
||||
}) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const listContainerRef = useRef(null)
|
||||
const conditionRefs = useRef([])
|
||||
const [filterName, setFilterName] = useState('')
|
||||
const [filterDescription, setFilterDescription] = useState('')
|
||||
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
|
||||
const [conditions, setConditions] = useState([
|
||||
{ type: 'assignee', operator: 'is', value: [] },
|
||||
])
|
||||
const [selections, setSelections] = useState(defaultSelections())
|
||||
const [error, setError] = useState('')
|
||||
const { data: existedFilters = [] } = useFilters()
|
||||
|
||||
const filterNameExists = (name, excludeId = null) => {
|
||||
return existedFilters.some(
|
||||
filter =>
|
||||
filter.name.toLowerCase() === name.toLowerCase() &&
|
||||
filter.id !== excludeId,
|
||||
const filterNameExists = (name, excludeId = null) =>
|
||||
existedFilters.some(
|
||||
f => f.name.toLowerCase() === name.toLowerCase() && f.id !== excludeId,
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize refs array when conditions change
|
||||
useEffect(() => {
|
||||
conditionRefs.current = conditionRefs.current.slice(0, conditions.length)
|
||||
}, [conditions.length])
|
||||
|
||||
// Initialize state when editing a filter
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (editingFilter) {
|
||||
setFilterName(editingFilter.name)
|
||||
setFilterDescription(editingFilter.description || '')
|
||||
setFilterColor(editingFilter.color || FILTER_COLORS[0].value)
|
||||
setConditions(editingFilter.conditions || [])
|
||||
setError('')
|
||||
setSelections(conditionsToSelections(editingFilter.conditions))
|
||||
} else {
|
||||
setFilterName('')
|
||||
setFilterDescription('')
|
||||
// find color no filter has it :
|
||||
const potentialColor = FILTER_COLORS.find(
|
||||
color => !existedFilters.some(filter => filter.color === color.value),
|
||||
c => !existedFilters.some(f => f.color === c.value),
|
||||
)
|
||||
|
||||
setFilterColor(
|
||||
potentialColor ? potentialColor.value : FILTER_COLORS[0].value,
|
||||
)
|
||||
setConditions([{ type: 'assignee', operator: 'is', value: [] }])
|
||||
setError('')
|
||||
setFilterColor(potentialColor?.value ?? FILTER_COLORS[0].value)
|
||||
setSelections(defaultSelections())
|
||||
}
|
||||
setError('')
|
||||
}, [editingFilter, isOpen])
|
||||
|
||||
const conditions = useMemo(() => selectionsToConditions(selections), [selections])
|
||||
|
||||
const previewChores = useMemo(() => {
|
||||
const validConditions = conditions.filter(c => {
|
||||
if (c.type === 'dueDate' || c.type === 'points') return true
|
||||
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
|
||||
})
|
||||
|
||||
if (validConditions.length === 0) return []
|
||||
|
||||
const result = applyFilter(
|
||||
if (conditions.length === 0) return []
|
||||
return applyFilter(
|
||||
allChores,
|
||||
{ conditions: validConditions, operator: 'AND' },
|
||||
{
|
||||
userId: userProfile?.id,
|
||||
members,
|
||||
labels,
|
||||
projects,
|
||||
},
|
||||
{ conditions, operator: 'AND' },
|
||||
{ userId: userProfile?.id, members, labels, projects },
|
||||
)
|
||||
|
||||
return result
|
||||
}, [conditions, allChores, userProfile, members, labels, projects])
|
||||
|
||||
const previewCount = previewChores.length
|
||||
const previewOverdueCount = previewChores.filter(
|
||||
chore => chore.nextDueDate && new Date(chore.nextDueDate) < new Date(),
|
||||
c => c.nextDueDate && new Date(c.nextDueDate) < new Date(),
|
||||
).length
|
||||
|
||||
const addCondition = () => {
|
||||
setConditions([
|
||||
...conditions,
|
||||
{ type: 'assignee', operator: 'is', value: [] },
|
||||
])
|
||||
|
||||
// Scroll to the new condition after it's rendered
|
||||
setTimeout(() => {
|
||||
const newIndex = conditions.length
|
||||
const newConditionElement = conditionRefs.current[newIndex]
|
||||
if (newConditionElement && listContainerRef.current) {
|
||||
newConditionElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const removeCondition = index => {
|
||||
setConditions(conditions.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const updateCondition = (index, field, value) => {
|
||||
const updated = [...conditions]
|
||||
updated[index] = { ...updated[index], [field]: value }
|
||||
|
||||
if (field === 'type') {
|
||||
updated[index].value = []
|
||||
if (value === 'dueDate') {
|
||||
updated[index].operator = 'isOverdue'
|
||||
updated[index].value = null
|
||||
} else if (value === 'status') {
|
||||
updated[index].value = []
|
||||
} else if (value === 'points') {
|
||||
updated[index].operator = 'greaterThan'
|
||||
updated[index].value = 0
|
||||
}
|
||||
}
|
||||
|
||||
setConditions(updated)
|
||||
}
|
||||
const activeConditionCount = conditions.length
|
||||
|
||||
const handleSave = () => {
|
||||
if (!filterName.trim()) {
|
||||
setError('Please enter a filter name')
|
||||
return
|
||||
}
|
||||
|
||||
// Check for duplicate name, excluding current filter if editing
|
||||
if (filterNameExists(filterName.trim(), editingFilter?.id)) {
|
||||
setError('A filter with this name already exists')
|
||||
return
|
||||
}
|
||||
|
||||
const validConditions = conditions.filter(c => {
|
||||
if (c.type === 'dueDate' || c.type === 'points') return true
|
||||
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
|
||||
})
|
||||
|
||||
if (conditions.length === 0 || validConditions.length === 0) {
|
||||
setError('Please add at least one filter condition')
|
||||
if (conditions.length === 0) {
|
||||
setError('Please configure at least one filter condition')
|
||||
return
|
||||
}
|
||||
|
||||
const filterData = {
|
||||
onSave({
|
||||
name: filterName.trim(),
|
||||
description: filterDescription.trim(),
|
||||
description: editingFilter?.description ?? '',
|
||||
color: filterColor,
|
||||
conditions: validConditions,
|
||||
conditions,
|
||||
operator: 'AND',
|
||||
}
|
||||
|
||||
// Include ID if editing
|
||||
if (editingFilter) {
|
||||
filterData.id = editingFilter.id
|
||||
}
|
||||
|
||||
onSave(filterData)
|
||||
...(editingFilter ? { id: editingFilter.id } : {}),
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
const renderValueSelector = (condition, index) => {
|
||||
switch (condition.type) {
|
||||
case 'assignee':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select assignees'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((selectedElement, idx) => {
|
||||
const value = selectedElement.value
|
||||
const member = members.find(
|
||||
m => String(m.userId) === String(value),
|
||||
)
|
||||
return (
|
||||
<Chip key={`${value}-${idx}`} size='sm'>
|
||||
{member?.displayName || member?.username || 'Unknown'}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{members.map((member, idx) => (
|
||||
<Option
|
||||
key={`member-${member.userId}-${idx}`}
|
||||
value={member.userId}
|
||||
>
|
||||
{member.displayName || member.username} ({member.userId})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'createdBy':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select creators'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((selectedElement, idx) => {
|
||||
const value = selectedElement.value
|
||||
|
||||
const member = members.find(
|
||||
m => String(m.userId) === String(value),
|
||||
)
|
||||
return (
|
||||
<Chip key={`${value}-${idx}`} size='sm'>
|
||||
{member?.displayName || member?.username || 'Unknown'}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{members.map((member, idx) => (
|
||||
<Option
|
||||
key={`creator-${member.userId}-${idx}`}
|
||||
value={member.userId}
|
||||
>
|
||||
{member.displayName || member.username}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'priority':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select priorities'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((selectedElement, idx) => {
|
||||
const value = selectedElement.value
|
||||
const priority = Priorities.find(p => p.value === value)
|
||||
return (
|
||||
<Chip key={`priority-${value}-${idx}`} size='sm'>
|
||||
{priority?.name || `Priority ${value}`}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{Priorities.map((priority, idx) => (
|
||||
<Option
|
||||
key={`priority-opt-${priority.value}-${idx}`}
|
||||
value={priority.value}
|
||||
>
|
||||
{priority.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'label':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select labels'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((selectedElement, idx) => {
|
||||
const value = selectedElement.value
|
||||
|
||||
const label = labels.find(l => String(l.id) === String(value))
|
||||
return (
|
||||
<Chip key={`label-chip-${value}-${idx}`} size='sm'>
|
||||
{label?.name || 'Unknown'}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{labels.map((label, idx) => (
|
||||
<Option key={`label-opt-${label.id}-${idx}`} value={label.id}>
|
||||
{label.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'project':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select projects'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((event, idx) => {
|
||||
const value = event.value
|
||||
if (value === 'default')
|
||||
return (
|
||||
<Chip key={`default-${idx}`} size='sm'>
|
||||
Default
|
||||
</Chip>
|
||||
)
|
||||
const project = projects.find(
|
||||
p => String(p.id) === String(value),
|
||||
)
|
||||
return (
|
||||
<Chip key={`project-chip-${value}-${idx}`} size='sm'>
|
||||
{project?.name || 'Unknown'}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<Option value='default'>Default Project</Option>
|
||||
{projects
|
||||
.filter(p => p.id !== 'default')
|
||||
.map((project, idx) => (
|
||||
<Option
|
||||
key={`project-opt-${project.id}-${idx}`}
|
||||
value={project.id}
|
||||
>
|
||||
{project.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'status':
|
||||
return (
|
||||
<Select
|
||||
multiple
|
||||
value={condition.value || []}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'value', newValue)
|
||||
}
|
||||
placeholder='Select statuses'
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{selected.map((selectedElement, idx) => {
|
||||
const value = selectedElement.value
|
||||
const statusLabels = {
|
||||
0: 'Active',
|
||||
1: 'Started',
|
||||
2: 'In Progress',
|
||||
3: 'Pending Approval',
|
||||
}
|
||||
return (
|
||||
<Chip key={`status-chip-${value}-${idx}`} size='sm'>
|
||||
{statusLabels[value] || 'Unknown'}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<Option value={0}>Active</Option>
|
||||
<Option value={1}>Started</Option>
|
||||
<Option value={2}>In Progress</Option>
|
||||
<Option value={3}>Pending Approval</Option>
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'dueDate':
|
||||
return (
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'operator', newValue)
|
||||
}
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Option value='isOverdue'>Is Overdue</Option>
|
||||
<Option value='isDueToday'>Is Due Today</Option>
|
||||
<Option value='isDueTomorrow'>Is Due Tomorrow</Option>
|
||||
<Option value='isDueThisWeek'>Is Due This Week</Option>
|
||||
<Option value='isDueThisMonth'>Is Due This Month</Option>
|
||||
<Option value='hasNoDueDate'>Has No Due Date</Option>
|
||||
<Option value='hasDueDate'>Has Due Date</Option>
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'points':
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 1, width: '100%' }}>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'operator', newValue)
|
||||
}
|
||||
sx={{ flex: 1 }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Option value='equals'>Equals</Option>
|
||||
<Option value='greaterThan'>Greater Than</Option>
|
||||
<Option value='lessThan'>Less Than</Option>
|
||||
<Option value='greaterThanOrEqual'>Greater Than or Equal</Option>
|
||||
<Option value='lessThanOrEqual'>Less Than or Equal</Option>
|
||||
</Select>
|
||||
<Input
|
||||
type='number'
|
||||
value={condition.value ?? 0}
|
||||
onChange={e =>
|
||||
updateCondition(index, 'value', parseInt(e.target.value) || 0)
|
||||
}
|
||||
sx={{ flex: 1 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
<BottomSheetModal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
title={editingFilter ? 'Edit Filter' : 'Create Advanced Filter'}
|
||||
maxHeight='92vh'
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{editingFilter ? 'Edit Filter' : 'New Filter'}
|
||||
{activeConditionCount > 0 && (
|
||||
<Chip size='sm' variant='solid' color='primary'>
|
||||
{activeConditionCount} condition{activeConditionCount !== 1 ? 's' : ''}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button variant='outlined' color='neutral' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='solid' color='primary' onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{/* Preview */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
|
||||
{conditions.length > 0 ? (
|
||||
<>
|
||||
<Chip size='sm' variant='soft' color='neutral'>
|
||||
{previewCount} task{previewCount !== 1 ? 's' : ''}
|
||||
</Chip>
|
||||
{previewOverdueCount > 0 && (
|
||||
<Chip size='sm' variant='solid' color='danger'>
|
||||
{previewOverdueCount} overdue
|
||||
</Chip>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
Add conditions to preview
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant='plain' color='neutral' size='sm' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='sm'
|
||||
startDecorator={<Save sx={{ fontSize: 16 }} />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Filter
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Name */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
|
||||
>
|
||||
Filter Name
|
||||
</Typography>
|
||||
<Input
|
||||
placeholder='e.g. Important Tasks due soon, Tasks for John, etc.'
|
||||
placeholder='e.g. Overdue tasks for Alice'
|
||||
value={filterName}
|
||||
onChange={e => {
|
||||
setFilterName(e.target.value)
|
||||
@@ -560,253 +181,57 @@ const AdvancedFilterBuilder = ({
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
|
||||
<Typography level='body-xs' color='danger' sx={{ mt: 0.5 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
Description (Optional)
|
||||
</Typography>
|
||||
<Textarea
|
||||
placeholder='Optional description for this filter...'
|
||||
value={filterDescription}
|
||||
onChange={e => setFilterDescription(e.target.value)}
|
||||
minRows={2}
|
||||
maxRows={3}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
{/* Color */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
|
||||
>
|
||||
Color
|
||||
</Typography>
|
||||
<Select
|
||||
value={filterColor}
|
||||
onChange={(_, value) => value && setFilterColor(value)}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<Typography
|
||||
startDecorator={
|
||||
<Box
|
||||
sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
background: selected.value,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{selected.label}
|
||||
</Typography>
|
||||
)}
|
||||
>
|
||||
{FILTER_COLORS.map(color => (
|
||||
<Option key={color.value} value={color.value}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
background: color.value,
|
||||
}}
|
||||
/>
|
||||
<Typography>{color.name}</Typography>
|
||||
</Box>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
Filter Conditions (All must match)
|
||||
</Typography>
|
||||
|
||||
<List
|
||||
ref={listContainerRef}
|
||||
sx={{
|
||||
gap: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
maxHeight: { xs: '40vh', sm: '50vh' },
|
||||
pr: 0.5,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{conditions.map((condition, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
ref={el => (conditionRefs.current[index] = el)}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{FILTER_COLORS.map(c => (
|
||||
<Box
|
||||
key={c.value}
|
||||
title={c.name}
|
||||
onClick={() => setFilterColor(c.value)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
p: 1.5,
|
||||
bgcolor: 'background.level1',
|
||||
borderRadius: 'sm',
|
||||
position: 'relative',
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: '50%',
|
||||
background: c.value,
|
||||
cursor: 'pointer',
|
||||
outline:
|
||||
filterColor === c.value
|
||||
? '3px solid var(--joy-palette-primary-500)'
|
||||
: '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
transition: 'all 0.15s ease',
|
||||
flexShrink: 0,
|
||||
'&:hover': { transform: 'scale(1.2)' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Condition {index + 1}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size='sm'
|
||||
color='danger'
|
||||
variant='plain'
|
||||
onClick={() => removeCondition(index)}
|
||||
disabled={conditions.length === 1}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography level='body-xs' sx={{ mb: 0.5 }}>
|
||||
Field
|
||||
</Typography>
|
||||
<Select
|
||||
value={condition.type}
|
||||
onChange={(_, newValue) =>
|
||||
updateCondition(index, 'type', newValue)
|
||||
}
|
||||
sx={{ width: '100%' }}
|
||||
slotProps={{
|
||||
listbox: {
|
||||
placement: 'bottom-start',
|
||||
disablePortal: false,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Option value='assignee'>Assignee</Option>
|
||||
<Option value='createdBy'>Created By</Option>
|
||||
<Option value='priority'>Priority</Option>
|
||||
<Option value='label'>Label</Option>
|
||||
<Option value='project'>Project</Option>
|
||||
<Option value='status'>Status</Option>
|
||||
<Option value='dueDate'>Due Date</Option>
|
||||
<Option value='points'>Points</Option>
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography level='body-xs' sx={{ mb: 0.5 }}>
|
||||
{condition.type === 'dueDate' || condition.type === 'points'
|
||||
? 'Condition'
|
||||
: 'Value'}
|
||||
</Typography>
|
||||
{renderValueSelector(condition, index)}
|
||||
</Box>
|
||||
</ListItem>
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
startDecorator={<Add />}
|
||||
onClick={addCondition}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Add Condition
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Preview</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip size='sm' variant='soft' color='neutral'>
|
||||
{previewCount} tasks
|
||||
</Chip>
|
||||
{previewOverdueCount > 0 && (
|
||||
<Chip size='sm' variant='solid' color='danger'>
|
||||
{previewOverdueCount} overdue
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: 150,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
bgcolor: 'background.level1',
|
||||
p: 1,
|
||||
borderRadius: 'sm',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{previewCount === 0 ? (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
color='neutral'
|
||||
sx={{ textAlign: 'center', py: 2 }}
|
||||
>
|
||||
No tasks match these filters
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{previewChores.slice(0, 3).map(chore => (
|
||||
<Box
|
||||
key={chore.id}
|
||||
sx={{
|
||||
bgcolor: 'background.surface',
|
||||
p: 1,
|
||||
borderRadius: 'sm',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>{chore.name}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{previewCount > 3 && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
color='neutral'
|
||||
sx={{ textAlign: 'center', mt: 0.5 }}
|
||||
>
|
||||
...and {previewCount - 3} more
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2.5 }} />
|
||||
|
||||
<FilterBuilderContent
|
||||
selections={selections}
|
||||
onSelectionsChange={setSelections}
|
||||
members={members}
|
||||
labels={labels}
|
||||
projects={projects}
|
||||
/>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</BottomSheetModal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
138
src/views/Modals/Inputs/AttachmentBrowserModal.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { AttachFile, Close, Image } from '@mui/icons-material'
|
||||
import { Box, Button, CircularProgress, List, ListItem, ListItemButton, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { GetChoreAttachments } from '../../../utils/Fetcher'
|
||||
import { resolvePhotoURL } from '../../../utils/Helpers'
|
||||
import AttachmentViewerModal from './AttachmentViewerModal'
|
||||
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
|
||||
|
||||
const isImageFile = fileName => {
|
||||
if (!fileName) return false
|
||||
const ext = fileName.split('.').pop().toLowerCase()
|
||||
return IMAGE_EXTENSIONS.includes(ext)
|
||||
}
|
||||
|
||||
const downloadFile = (url, fileName) => {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName || 'attachment'
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [viewerConfig, setViewerConfig] = useState({ isOpen: false })
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !choreId) return
|
||||
setIsLoading(true)
|
||||
GetChoreAttachments(choreId)
|
||||
.then(async res => {
|
||||
if (!res.ok) throw new Error('Failed to fetch attachments')
|
||||
return res.json()
|
||||
})
|
||||
.then(data => setAttachments(Array.isArray(data) ? data : []))
|
||||
.catch(() => setAttachments([]))
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [isOpen, choreId])
|
||||
|
||||
const handleClose = () => {
|
||||
setAttachments([])
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
const handleAttachmentClick = attachment => {
|
||||
const url = resolvePhotoURL(attachment.sign)
|
||||
if (isImageFile(attachment.file_name)) {
|
||||
setViewerConfig({
|
||||
isOpen: true,
|
||||
url,
|
||||
fileName: attachment.file_name,
|
||||
onClose: () => setViewerConfig({ isOpen: false }),
|
||||
})
|
||||
} else {
|
||||
downloadFile(url, attachment.file_name)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveModal
|
||||
open={!!isOpen}
|
||||
onClose={handleClose}
|
||||
title='Attachments'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<Close />}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size='md' />
|
||||
</Box>
|
||||
) : attachments.length === 0 ? (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.secondary', py: 2, textAlign: 'center' }}
|
||||
>
|
||||
No attachments found.
|
||||
</Typography>
|
||||
) : (
|
||||
<List sx={{ '--ListItem-paddingX': '0px' }}>
|
||||
{attachments.map((attachment, index) => (
|
||||
<ListItem
|
||||
key={
|
||||
attachment.id ||
|
||||
attachment.file_path ||
|
||||
attachment.sign ||
|
||||
attachment.file_name ||
|
||||
index
|
||||
}
|
||||
sx={{ p: 0 }}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={() => handleAttachmentClick(attachment)}
|
||||
sx={{ borderRadius: 'sm', gap: 1.5, py: 1 }}
|
||||
>
|
||||
{isImageFile(attachment.file_name) ? (
|
||||
<Image fontSize='small' />
|
||||
) : (
|
||||
<AttachFile fontSize='small' />
|
||||
)}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography level='body-sm' noWrap>
|
||||
{attachment.file_name || `File ${index + 1}`}
|
||||
</Typography>
|
||||
{attachment.size_bytes > 0 && (
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
{(attachment.size_bytes / 1024).toFixed(1)} KB
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</ResponsiveModal>
|
||||
<AttachmentViewerModal config={viewerConfig} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AttachmentBrowserModal
|
||||
116
src/views/Modals/Inputs/AttachmentViewerModal.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Browser } from '@capacitor/browser'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Close, Download } from '@mui/icons-material'
|
||||
import { Box, Button, CircularProgress, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
|
||||
const openUrl = async url => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
await Browser.open({ url })
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
const downloadUrl = (url, fileName) => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
Browser.open({ url })
|
||||
} else {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName || 'attachment'
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
}
|
||||
|
||||
function AttachmentViewerModal({ config }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [imgLoaded, setImgLoaded] = useState(false)
|
||||
const [imgError, setImgError] = useState(false)
|
||||
|
||||
const { isOpen, url, fileName, onClose } = config || {}
|
||||
|
||||
const handleClose = () => {
|
||||
setImgLoaded(false)
|
||||
setImgError(false)
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={!!isOpen}
|
||||
onClose={handleClose}
|
||||
title={fileName || 'Attachment'}
|
||||
maxHeight='92vh'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<Close />}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
startDecorator={<Download />}
|
||||
onClick={() => downloadUrl(url, fileName)}
|
||||
disabled={!url}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 200,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{!imgLoaded && !imgError && (
|
||||
<CircularProgress
|
||||
sx={{ position: 'absolute' }}
|
||||
size='md'
|
||||
/>
|
||||
)}
|
||||
{imgError ? (
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Failed to load image.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box
|
||||
component='img'
|
||||
src={url}
|
||||
alt={fileName}
|
||||
onClick={() => url && openUrl(url)}
|
||||
onLoad={() => setImgLoaded(true)}
|
||||
onError={() => {
|
||||
setImgLoaded(true)
|
||||
setImgError(true)
|
||||
}}
|
||||
sx={{
|
||||
cursor: url ? 'zoom-in' : 'default',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '65vh',
|
||||
borderRadius: 'md',
|
||||
objectFit: 'contain',
|
||||
display: imgLoaded && !imgError ? 'block' : 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default AttachmentViewerModal
|
||||
@@ -1,114 +1,175 @@
|
||||
import { CopyAll } from '@mui/icons-material'
|
||||
import { Box, Button, Checkbox, Input, ListItem, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
Input,
|
||||
ListItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { startNativeNFCWrite } from '../../../service/NFCWriter'
|
||||
|
||||
function WriteNFCModal({ config }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle', 'writing', 'success', 'error'
|
||||
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle' | 'writing' | 'waiting_for_tag' | 'success' | 'error'
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [isAutoCompleteWhenScan, setIsAutoCompleteWhenScan] = useState(false)
|
||||
const cancelScanRef = useRef(null)
|
||||
const isNative = Capacitor.isNativePlatform()
|
||||
|
||||
const requestNFCAccess = async () => {
|
||||
if ('NDEFReader' in window) {
|
||||
// Assuming permission request is implicit in 'write' or 'scan' methods
|
||||
setNfcStatus('idle')
|
||||
} else {
|
||||
alert('NFC is not supported by this browser.')
|
||||
}
|
||||
const getURL = () => {
|
||||
let url = config.url
|
||||
if (isAutoCompleteWhenScan) url += '?auto_complete=true'
|
||||
return url
|
||||
}
|
||||
|
||||
const writeToNFC = async url => {
|
||||
if ('NDEFReader' in window) {
|
||||
try {
|
||||
const ndef = new window.NDEFReader()
|
||||
await ndef.write({
|
||||
records: [{ recordType: 'url', data: url }],
|
||||
})
|
||||
setNfcStatus('success')
|
||||
} catch (error) {
|
||||
console.error('Error writing to NFC tag:', error)
|
||||
setNfcStatus('error')
|
||||
setErrorMessage('Error writing to NFC tag. Please try again.')
|
||||
}
|
||||
} else {
|
||||
setNfcStatus('error')
|
||||
setErrorMessage(
|
||||
'NFC is not supported by this browser. You can still copy the URL and write it to an NFC tag using a compatible device.',
|
||||
)
|
||||
const handleClose = async () => {
|
||||
if (cancelScanRef.current) {
|
||||
await cancelScanRef.current()
|
||||
cancelScanRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
config.onClose()
|
||||
setNfcStatus('idle')
|
||||
setErrorMessage('')
|
||||
}
|
||||
const getURL = () => {
|
||||
let url = config.url
|
||||
if (isAutoCompleteWhenScan) {
|
||||
url = url + '?auto_complete=true'
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (cancelScanRef.current) {
|
||||
await cancelScanRef.current()
|
||||
cancelScanRef.current = null
|
||||
}
|
||||
setNfcStatus('idle')
|
||||
}
|
||||
|
||||
const writeToNFC = async () => {
|
||||
const url = getURL()
|
||||
|
||||
if (isNative) {
|
||||
setNfcStatus('writing')
|
||||
const cancel = await startNativeNFCWrite(url, {
|
||||
onWaiting: () => setNfcStatus('waiting_for_tag'),
|
||||
onSuccess: () => {
|
||||
cancelScanRef.current = null
|
||||
setNfcStatus('success')
|
||||
},
|
||||
onError: msg => {
|
||||
cancelScanRef.current = null
|
||||
setNfcStatus('error')
|
||||
setErrorMessage(msg)
|
||||
},
|
||||
})
|
||||
cancelScanRef.current = cancel
|
||||
} else {
|
||||
if ('NDEFReader' in window) {
|
||||
try {
|
||||
setNfcStatus('writing')
|
||||
const ndef = new window.NDEFReader()
|
||||
await ndef.write({ records: [{ recordType: 'url', data: url }] })
|
||||
setNfcStatus('success')
|
||||
} catch (error) {
|
||||
console.error('Error writing to NFC tag:', error)
|
||||
setNfcStatus('error')
|
||||
setErrorMessage('Error writing to NFC tag. Please try again.')
|
||||
}
|
||||
} else {
|
||||
setNfcStatus('error')
|
||||
setErrorMessage(
|
||||
'NFC is not supported by this browser. You can still copy the URL and write it to an NFC tag using a compatible device.',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderBody = () => {
|
||||
if (nfcStatus === 'success') {
|
||||
return (
|
||||
<Typography level='body-md' gutterBottom>
|
||||
URL written to NFC tag successfully!
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
return url
|
||||
if (nfcStatus === 'waiting_for_tag') {
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
display='flex'
|
||||
flexDirection='column'
|
||||
alignItems='center'
|
||||
gap={2}
|
||||
py={3}
|
||||
>
|
||||
<CircularProgress size='lg' />
|
||||
<Typography level='body-md' textAlign='center'>
|
||||
Hold your device near the NFC tag
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
fullWidth
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography level='body-md' gutterBottom>
|
||||
{nfcStatus === 'error'
|
||||
? errorMessage
|
||||
: 'Press the button below to write to NFC.'}
|
||||
</Typography>
|
||||
<Input
|
||||
value={getURL()}
|
||||
fullWidth
|
||||
readOnly
|
||||
label='URL'
|
||||
sx={{ mt: 1 }}
|
||||
endDecorator={
|
||||
<CopyAll
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(getURL())
|
||||
alert('URL copied to clipboard!')
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem>
|
||||
<Checkbox
|
||||
checked={isAutoCompleteWhenScan}
|
||||
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
|
||||
label='Auto-complete when scanned'
|
||||
/>
|
||||
</ListItem>
|
||||
<Box display='flex' justifyContent='space-around' mt={1}>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={writeToNFC}
|
||||
fullWidth
|
||||
disabled={nfcStatus === 'writing'}
|
||||
>
|
||||
Write NFC
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal open={config?.isOpen} onClose={handleClose}>
|
||||
<Typography level='h4' mb={1}>
|
||||
{nfcStatus === 'success' ? 'Success!' : 'Write to NFC'}
|
||||
</Typography>
|
||||
|
||||
{nfcStatus === 'success' ? (
|
||||
<Typography level='body-md' gutterBottom>
|
||||
URL written to NFC tag successfully!
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
<Typography level='body-md' gutterBottom>
|
||||
{nfcStatus === 'error'
|
||||
? errorMessage
|
||||
: 'Press the button below to write to NFC.'}
|
||||
</Typography>
|
||||
<Input
|
||||
value={getURL()}
|
||||
fullWidth
|
||||
readOnly
|
||||
label='URL'
|
||||
sx={{ mt: 1 }}
|
||||
endDecorator={
|
||||
<CopyAll
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(getURL())
|
||||
alert('URL copied to clipboard!')
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem>
|
||||
<Checkbox
|
||||
checked={isAutoCompleteWhenScan}
|
||||
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
|
||||
label='Auto-complete when scanned'
|
||||
/>
|
||||
</ListItem>
|
||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => writeToNFC(getURL())}
|
||||
fullWidth
|
||||
sx={{ mr: 1 }}
|
||||
disabled={nfcStatus === 'writing'}
|
||||
>
|
||||
Write NFC
|
||||
</Button>
|
||||
<Button size='lg' onClick={requestNFCAccess} variant='outlined'>
|
||||
Request Access
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{renderBody()}
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react'
|
||||
|
||||
const PrivacyPolicyView = () => {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -29,7 +29,10 @@ const AccountSettings = () => {
|
||||
async function configurePurchases() {
|
||||
if (Capacitor.isNativePlatform() && userProfile) {
|
||||
await Purchases.configure({
|
||||
apiKey: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY,
|
||||
apiKey:
|
||||
Capacitor.getPlatform() === 'ios'
|
||||
? import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_IOS
|
||||
: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_ANDROID,
|
||||
appUserID: String(userProfile?.id),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,13 +25,12 @@ import SettingsLayout from './SettingsLayout'
|
||||
const ProfileSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const queryClient = useQueryClient()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
|
||||
const { showSuccess, showError } = useNotification()
|
||||
const [displayName, setDisplayName] = useState(userProfile?.displayName || '')
|
||||
const [timezone, setTimezone] = useState(
|
||||
userProfile?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
)
|
||||
const [photoURL, setPhotoURL] = useState(userProfile?.image || '')
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const fileInputRef = useRef()
|
||||
@@ -89,10 +88,9 @@ const ProfileSettings = () => {
|
||||
formData.append('file', compressedFile, 'profile.jpg')
|
||||
const response = await apiClient.upload('/users/profile_photo', formData)
|
||||
if (!response.ok) throw new Error('Upload failed')
|
||||
const data = await response.json()
|
||||
const url = resolvePhotoURL(data.url || data.sign)
|
||||
await response.json()
|
||||
|
||||
setPhotoURL(url)
|
||||
refetchUserProfile() // Refresh user profile to get the new photoURL
|
||||
showSuccess({
|
||||
title: t('profile.photoUpdated'),
|
||||
message: t('profile.photoUpdatedMessage'),
|
||||
@@ -155,7 +153,7 @@ const ProfileSettings = () => {
|
||||
maxWidth: 400,
|
||||
}}
|
||||
>
|
||||
<Avatar src={photoURL} sx={{ width: 64, height: 64 }} />
|
||||
<Avatar src={resolvePhotoURL(userProfile?.image)} sx={{ width: 64, height: 64 }} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Button
|
||||
variant='soft'
|
||||
|
||||
@@ -139,7 +139,10 @@ const Settings = () => {
|
||||
async function configurePurchases() {
|
||||
if (Capacitor.isNativePlatform() && userProfile) {
|
||||
await Purchases.configure({
|
||||
apiKey: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY,
|
||||
apiKey:
|
||||
Capacitor.getPlatform() === 'ios'
|
||||
? import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_IOS
|
||||
: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_ANDROID,
|
||||
appUserID: String(userProfile?.id),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ import { Cell, Pie, PieChart, Tooltip } from 'recharts'
|
||||
|
||||
import {
|
||||
AccessTime,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
Checklist,
|
||||
EventBusy,
|
||||
EventNote,
|
||||
Group,
|
||||
HourglassEmpty,
|
||||
Person,
|
||||
Redo,
|
||||
RunningWithErrors,
|
||||
Schedule,
|
||||
Style,
|
||||
ThumbDown,
|
||||
Timeline,
|
||||
Toll,
|
||||
@@ -24,23 +27,27 @@ import {
|
||||
Divider,
|
||||
Grid,
|
||||
Link,
|
||||
Option,
|
||||
Select,
|
||||
Stack,
|
||||
Tab,
|
||||
TabList,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
|
||||
import {
|
||||
useChores,
|
||||
useChoresHistory,
|
||||
useDeleteChoreHistory,
|
||||
useUpdateChoreHistory,
|
||||
} from '../../queries/ChoreQueries'
|
||||
import EditHistoryModal from '../Modals/EditHistoryModal'
|
||||
import HistoryDetailModal from '../Modals/HistoryDetailModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import { ChoresGrouper } from '../../utils/Chores'
|
||||
import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
|
||||
const groupByDate = history => {
|
||||
@@ -58,47 +65,57 @@ const groupByDate = history => {
|
||||
return aggregated
|
||||
}
|
||||
|
||||
const ChoreHistoryItem = ({ time, name, points, status, performer, notes, onViewNote }) => {
|
||||
const getStatusIcon = status => {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return <AccessTime color='primary' />
|
||||
case 1:
|
||||
return <Check color='success' />
|
||||
case 2:
|
||||
return <Redo color='warning' />
|
||||
case 3:
|
||||
return <HourglassEmpty color='neutral' />
|
||||
case 4:
|
||||
return <ThumbDown color='error' />
|
||||
case 5:
|
||||
return <RunningWithErrors color='error' />
|
||||
case 6:
|
||||
return <Schedule color='warning' />
|
||||
default:
|
||||
return <Check color='success' />
|
||||
}
|
||||
}
|
||||
const statusConfig = {
|
||||
0: { color: 'primary', icon: <AccessTime /> },
|
||||
1: { color: 'success', icon: <Check /> },
|
||||
2: { color: 'warning', icon: <Redo /> },
|
||||
3: { color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { color: 'danger', icon: <ThumbDown /> },
|
||||
5: { color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { color: 'warning', icon: <Schedule /> },
|
||||
}
|
||||
|
||||
const ChoreHistoryItem = ({
|
||||
time,
|
||||
name,
|
||||
points,
|
||||
status,
|
||||
notes,
|
||||
onViewNote,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
const cfg = statusConfig[status] ?? statusConfig[1]
|
||||
|
||||
return (
|
||||
<Stack direction='row' alignItems='center' spacing={2}>
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
spacing={1}
|
||||
onClick={onViewDetails}
|
||||
sx={{
|
||||
cursor: onViewDetails ? 'pointer' : 'default',
|
||||
borderRadius: 'sm',
|
||||
'&:hover': onViewDetails
|
||||
? { backgroundColor: 'background.level1' }
|
||||
: {},
|
||||
}}
|
||||
>
|
||||
<Typography level='body-md' sx={{ minWidth: 80 }}>
|
||||
{time}
|
||||
</Typography>
|
||||
<Box
|
||||
<Avatar
|
||||
size='sm'
|
||||
color={cfg.color}
|
||||
variant='soft'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'background.level2',
|
||||
boxShadow: 'sm',
|
||||
width: 32,
|
||||
height: 32,
|
||||
flexShrink: 0,
|
||||
'& svg': { fontSize: '16px' },
|
||||
}}
|
||||
>
|
||||
{getStatusIcon(status)}
|
||||
</Box>
|
||||
{cfg.icon}
|
||||
</Avatar>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -143,25 +160,18 @@ const ChoreHistoryItem = ({ time, name, points, status, performer, notes, onView
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const ChoreHistoryTimeline = ({ history, onViewNote }) => {
|
||||
const ChoreHistoryTimeline = ({
|
||||
history,
|
||||
performers,
|
||||
onViewNote,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
const { fmt } = useLocalization()
|
||||
|
||||
const groupedHistory = groupByDate(history)
|
||||
|
||||
const sortedEntries = Object.entries(groupedHistory).sort(
|
||||
([a], [b]) => new Date(b) - new Date(a),
|
||||
)
|
||||
|
||||
return (
|
||||
<Container sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Timeline sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
|
||||
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
|
||||
Activities Timeline
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ py: 2, width: '100%' }}>
|
||||
{Object.entries(groupedHistory).map(([date, items]) => (
|
||||
<Box key={date} sx={{ mb: 4 }}>
|
||||
<Typography level='title-sm' sx={{ mb: 0.5 }}>
|
||||
@@ -170,25 +180,21 @@ const ChoreHistoryTimeline = ({ history, onViewNote }) => {
|
||||
<Divider />
|
||||
<Stack spacing={1}>
|
||||
{items.map(record => (
|
||||
<>
|
||||
<ChoreHistoryItem
|
||||
key={record.id}
|
||||
|
||||
time={fmt.time(
|
||||
record.performedAt || record.updatedAt,
|
||||
)}
|
||||
name={record.choreName}
|
||||
points={record.points}
|
||||
status={record.status}
|
||||
notes={record.notes}
|
||||
onViewNote={onViewNote}
|
||||
/>
|
||||
</>
|
||||
<ChoreHistoryItem
|
||||
key={record.id}
|
||||
time={fmt.time(record.performedAt || record.updatedAt)}
|
||||
name={record.choreName}
|
||||
points={record.points}
|
||||
status={record.status}
|
||||
notes={record.notes}
|
||||
onViewNote={onViewNote}
|
||||
onViewDetails={() => onViewDetails?.(record, performers)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -402,6 +408,11 @@ const UserActivites = () => {
|
||||
const [enrichedHistory, setEnrichedHistory] = React.useState([])
|
||||
const [selectedChart, setSelectedChart] = React.useState('history')
|
||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
|
||||
const [editModalConfig, setEditModalConfig] = useState({ isOpen: false })
|
||||
const [editHistoryRecord, setEditHistoryRecord] = useState(null)
|
||||
const updateChoreHistory = useUpdateChoreHistory()
|
||||
const deleteChoreHistory = useDeleteChoreHistory()
|
||||
|
||||
const [historyPieChartData, setHistoryPieChartData] = React.useState([])
|
||||
const [choreDuePieChartData, setChoreDuePieChartData] = React.useState([])
|
||||
@@ -416,6 +427,7 @@ const UserActivites = () => {
|
||||
choresAssigneeBreakdownChartData,
|
||||
setChoresAssigneeBreakdownChartData,
|
||||
] = React.useState([])
|
||||
const { data: userLabels } = useLabels()
|
||||
const { data: choresData, isLoading: isChoresLoading } = useChores(true)
|
||||
const {
|
||||
data: choresHistory,
|
||||
@@ -432,6 +444,142 @@ const UserActivites = () => {
|
||||
}
|
||||
}, [circleMembersData])
|
||||
|
||||
// Client-side filters applied on top of the user+time-window slice
|
||||
const clientFilterDefs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
type: 'multi-select',
|
||||
icon: <Checklist />,
|
||||
options: [
|
||||
{ value: 1, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
|
||||
{ value: 2, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
|
||||
{ value: 3, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
|
||||
{ value: 4, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
|
||||
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
|
||||
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
|
||||
],
|
||||
filterFn: (item, values) => values.includes(item.status),
|
||||
},
|
||||
...(userLabels?.length > 0
|
||||
? [
|
||||
{
|
||||
id: 'label',
|
||||
label: 'Labels',
|
||||
type: 'multi-select',
|
||||
icon: <Style />,
|
||||
options: userLabels.map(l => ({
|
||||
value: l.id,
|
||||
label: l.name,
|
||||
icon: (
|
||||
<Box
|
||||
component='span'
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: l.color || '#90a4ae',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
filterFn: (item, values) =>
|
||||
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'hasNotes',
|
||||
label: 'Has Notes',
|
||||
type: 'boolean',
|
||||
icon: <EventNote />,
|
||||
filterFn: item => !!item.notes,
|
||||
},
|
||||
{
|
||||
id: 'hasPoints',
|
||||
label: 'Has Points',
|
||||
type: 'boolean',
|
||||
icon: <Toll />,
|
||||
filterFn: item => (item.points ?? 0) > 0,
|
||||
},
|
||||
],
|
||||
[userLabels],
|
||||
)
|
||||
|
||||
const {
|
||||
filteredData: filteredTimeline,
|
||||
activeFilters: clientActiveFilters,
|
||||
setFilter: setClientFilter,
|
||||
clearAll: clearClientFilters,
|
||||
} = useFilter(selectedHistory, clientFilterDefs)
|
||||
|
||||
// All filter defs merged for FilterBar display
|
||||
const filterDefs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'timePeriod',
|
||||
label: 'Time Period',
|
||||
type: 'single-select',
|
||||
icon: <CalendarMonth />,
|
||||
defaultValue: 7,
|
||||
options: [
|
||||
{ value: 7, label: '7 Days' },
|
||||
{ value: 30, label: '30 Days' },
|
||||
{ value: 90, label: '90 Days' },
|
||||
{ value: 365, label: 'All Time' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'completedBy',
|
||||
label: 'User',
|
||||
type: 'single-select',
|
||||
icon: <Person />,
|
||||
options: circleUsers.map(u => ({
|
||||
value: u.userId,
|
||||
label: u.displayName,
|
||||
avatar: u.image,
|
||||
})),
|
||||
},
|
||||
...clientFilterDefs,
|
||||
],
|
||||
[circleUsers, clientFilterDefs],
|
||||
)
|
||||
|
||||
// Merge server-driven and client-driven active filter states for the bar
|
||||
const activeFilters = useMemo(
|
||||
() => ({
|
||||
timePeriod: tabValue,
|
||||
...(selectedUser !== 'all' ? { completedBy: selectedUser } : {}),
|
||||
...clientActiveFilters,
|
||||
}),
|
||||
[tabValue, selectedUser, clientActiveFilters],
|
||||
)
|
||||
|
||||
const handleSetFilter = (id, value) => {
|
||||
if (id === 'completedBy') {
|
||||
const userId = value ?? 'all'
|
||||
setSelectedUser(userId)
|
||||
setSelectedHistory(enrichedHistory.filter(h => USER_FILTER(h, userId)))
|
||||
} else if (id === 'timePeriod') {
|
||||
const days = value ?? 7
|
||||
setTabValue(days)
|
||||
refetchHistory(days)
|
||||
} else {
|
||||
setClientFilter(id, value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
setSelectedUser('all')
|
||||
setSelectedHistory(enrichedHistory)
|
||||
setTabValue(7)
|
||||
refetchHistory(7)
|
||||
clearClientFilters()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isChoresHistoryLoading &&
|
||||
@@ -444,6 +592,7 @@ const UserActivites = () => {
|
||||
return {
|
||||
...item,
|
||||
choreName: chore?.name,
|
||||
labelsV2: chore?.labelsV2,
|
||||
}
|
||||
})
|
||||
setEnrichedHistory(enrichedHistory)
|
||||
@@ -823,222 +972,24 @@ const UserActivites = () => {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
{/* <EmojiEvents sx={{ fontSize: '2rem', color: '#FFD700' }} /> */}
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
level='h3'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
User Activities
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Overview of user activities and task statistics
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Filter Controls - Always visible */}
|
||||
<Card
|
||||
variant='outlined'
|
||||
sx={{
|
||||
width: '100%',
|
||||
p: 2,
|
||||
mb: 3,
|
||||
borderRadius: 12,
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Typography level='title-sm' sx={{ color: 'text.secondary' }}>
|
||||
Filter Activities
|
||||
</Typography>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: 'stretch', sm: 'center' }}
|
||||
>
|
||||
{/* User Filter */}
|
||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
|
||||
Show activities for:
|
||||
</Typography>
|
||||
<Select
|
||||
sx={{
|
||||
width: '100%',
|
||||
}}
|
||||
variant='outlined'
|
||||
value={selectedUser}
|
||||
onChange={(e, selected) => {
|
||||
setSelectedUser(selected)
|
||||
setSelectedHistory(
|
||||
enrichedHistory.filter(h => USER_FILTER(h, selected)),
|
||||
)
|
||||
}}
|
||||
renderValue={() => {
|
||||
if (selectedUser === undefined || selectedUser === 'all') {
|
||||
return (
|
||||
<Typography
|
||||
startDecorator={
|
||||
<Avatar color='primary' size='sm'>
|
||||
<Group />
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
All Users
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Typography
|
||||
startDecorator={
|
||||
<Avatar
|
||||
color='primary'
|
||||
size='sm'
|
||||
src={resolvePhotoURL(
|
||||
circleUsers.find(
|
||||
user => user.userId === selectedUser,
|
||||
)?.image,
|
||||
)}
|
||||
>
|
||||
{circleUsers
|
||||
.find(user => user.userId === selectedUser)
|
||||
?.displayName?.charAt(0)}
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
{
|
||||
circleUsers.find(user => user.userId === selectedUser)
|
||||
?.displayName
|
||||
}
|
||||
</Typography>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Option value='all'>
|
||||
<Typography
|
||||
startDecorator={
|
||||
<Avatar color='primary' size='sm'>
|
||||
<Group />
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
All Users
|
||||
</Typography>
|
||||
</Option>
|
||||
{circleUsers.map(user => (
|
||||
<Option key={user.userId} value={user.userId}>
|
||||
<Avatar
|
||||
color='primary'
|
||||
size='sm'
|
||||
src={resolvePhotoURL(user.image)}
|
||||
>
|
||||
{user.displayName?.charAt(0)}
|
||||
</Avatar>
|
||||
<Typography>{user.displayName}</Typography>
|
||||
<Chip
|
||||
color='success'
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<Toll />}
|
||||
>
|
||||
{user.points - user.pointsRedeemed}
|
||||
</Chip>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
{/* Time Period Filter */}
|
||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
|
||||
Time period:
|
||||
</Typography>
|
||||
<Tabs
|
||||
onChange={(e, tabValue) => {
|
||||
setTabValue(tabValue)
|
||||
refetchHistory(tabValue)
|
||||
}}
|
||||
value={tabValue}
|
||||
sx={{
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'background.surface',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<TabList
|
||||
disableUnderline
|
||||
sx={{
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'transparent',
|
||||
p: 0.5,
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: '7 Days', value: 7 },
|
||||
{ label: '30 Days', value: 30 },
|
||||
{ label: '90 Days', value: 90 },
|
||||
{ label: 'All Time', value: 365 },
|
||||
].map((tab, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
sx={{
|
||||
borderRadius: 6,
|
||||
minWidth: 'auto',
|
||||
px: 2,
|
||||
py: 1,
|
||||
fontSize: 'sm',
|
||||
fontWeight: 500,
|
||||
color: 'text.secondary',
|
||||
'&.Mui-selected': {
|
||||
color: 'primary.plainColor',
|
||||
backgroundColor: 'primary.softBg',
|
||||
fontWeight: 600,
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'neutral.softHoverBg',
|
||||
},
|
||||
}}
|
||||
disableIndicator
|
||||
value={tab.value}
|
||||
>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
</Tabs>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Current Filter Summary */}
|
||||
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Showing activities for{' '}
|
||||
<Typography
|
||||
component='span'
|
||||
sx={{ fontWeight: 600, color: 'primary.500' }}
|
||||
>
|
||||
{selectedUser === undefined || selectedUser === 'all'
|
||||
? 'All Users'
|
||||
: circleUsers.find(user => user.userId === selectedUser)
|
||||
?.displayName || 'Unknown User'}
|
||||
</Typography>{' '}
|
||||
over the{' '}
|
||||
<Typography
|
||||
component='span'
|
||||
sx={{ fontWeight: 600, color: 'primary.500' }}
|
||||
>
|
||||
{tabValue === 365 ? 'All Time' : `Last ${tabValue} Days`}
|
||||
</Typography>
|
||||
<Timeline sx={{ fontSize: '1.5rem' }} />
|
||||
<Typography
|
||||
level='title-md'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Activities
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FilterBar
|
||||
filterDefs={filterDefs}
|
||||
activeFilters={activeFilters}
|
||||
onSetFilter={handleSetFilter}
|
||||
onClearAll={handleClearAll}
|
||||
resultCount={filteredTimeline.length}
|
||||
totalCount={selectedHistory.length}
|
||||
/>
|
||||
|
||||
{/* Conditional Content Based on Data Availability */}
|
||||
{!choresData.res?.length > 0 || !choresHistory?.length > 0 ? (
|
||||
<Container
|
||||
@@ -1103,7 +1054,8 @@ const UserActivites = () => {
|
||||
{/* Left Side - Timeline (Mobile: Full width, Desktop: Flexible) */}
|
||||
<Box sx={{ flex: 1, minWidth: 0, width: '100%' }}>
|
||||
<ChoreHistoryTimeline
|
||||
history={selectedHistory}
|
||||
history={filteredTimeline}
|
||||
performers={circleUsers}
|
||||
onViewNote={notes => {
|
||||
setNoteViewerConfig({
|
||||
isOpen: true,
|
||||
@@ -1112,19 +1064,68 @@ const UserActivites = () => {
|
||||
onClose: () => setNoteViewerConfig({ isOpen: false }),
|
||||
})
|
||||
}}
|
||||
onViewDetails={(entry, performers) => {
|
||||
setDetailModalConfig({
|
||||
isOpen: true,
|
||||
entry,
|
||||
performers,
|
||||
onClose: () => setDetailModalConfig({ isOpen: false }),
|
||||
onEdit: record => {
|
||||
setDetailModalConfig(prev => ({ ...prev, isOpen: false }))
|
||||
setEditHistoryRecord(record)
|
||||
setEditModalConfig({
|
||||
isOpen: true,
|
||||
onClose: () => {
|
||||
setEditModalConfig({ isOpen: false })
|
||||
setEditHistoryRecord(null)
|
||||
},
|
||||
onSave: updated => {
|
||||
updateChoreHistory.mutate(
|
||||
{
|
||||
choreId: record.choreId,
|
||||
historyId: record.id,
|
||||
historyData: {
|
||||
performedAt: updated.performedAt,
|
||||
dueDate: updated.dueDate,
|
||||
notes: updated.notes,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditModalConfig({ isOpen: false })
|
||||
setEditHistoryRecord(null)
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
onDelete: () => {
|
||||
deleteChoreHistory.mutate(
|
||||
{ choreId: record.choreId, historyId: record.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditModalConfig({ isOpen: false })
|
||||
setEditHistoryRecord(null)
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Right Sidebar - Charts (Mobile: Full width, Desktop: Fixed width + sticky) */}
|
||||
{/* Right Sidebar - Charts (Desktop only, hidden on mobile) */}
|
||||
<Box
|
||||
sx={{
|
||||
width: { xs: '100%', lg: '350px' },
|
||||
position: { xs: 'static', lg: 'sticky' },
|
||||
top: { lg: '60px' },
|
||||
alignSelf: { lg: 'flex-start' },
|
||||
maxHeight: { lg: 'calc(100vh - 40px)' },
|
||||
overflowY: { lg: 'auto' },
|
||||
order: { xs: -1, lg: 1 }, // Show charts first on mobile, last on desktop
|
||||
display: { xs: 'none', lg: 'block' },
|
||||
width: '350px',
|
||||
position: 'sticky',
|
||||
top: '60px',
|
||||
alignSelf: 'flex-start',
|
||||
maxHeight: 'calc(100vh - 40px)',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Charts Container */}
|
||||
@@ -1263,6 +1264,11 @@ const UserActivites = () => {
|
||||
</>
|
||||
)}
|
||||
<NoteViewerModal config={noteViewerConfig} />
|
||||
<HistoryDetailModal config={detailModalConfig} />
|
||||
<EditHistoryModal
|
||||
config={editModalConfig}
|
||||
historyRecord={editHistoryRecord}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
43
src/views/components/AssigneePickerField.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Person } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const AssigneePickerField = ({
|
||||
value = null,
|
||||
onChange,
|
||||
onClear,
|
||||
members = [],
|
||||
includeAnyone = true,
|
||||
emptyDisplay,
|
||||
currentUserId = null,
|
||||
}) => {
|
||||
const options = [
|
||||
...(includeAnyone ? [{ userId: 'anyone', displayName: 'Anyone' }] : []),
|
||||
...members.map(member => ({
|
||||
userId: member.userId,
|
||||
displayName: member.displayName || member.username || 'Unknown',
|
||||
})),
|
||||
]
|
||||
|
||||
const displayValue = currentUserId && value === currentUserId ? null : value
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={displayValue}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Assignee'
|
||||
getItemValue={item => item.userId}
|
||||
getItemLabel={item => item.displayName}
|
||||
renderTriggerIcon={() => <Person sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={() => <Person sx={{ fontSize: '18px' }} />}
|
||||
getTriggerText={({ selectedItems, isEmpty }) =>
|
||||
isEmpty ? 'Assignee' : selectedItems[0].displayName
|
||||
}
|
||||
menuMinWidth={220}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssigneePickerField
|
||||
259
src/views/components/AttachmentPickerField.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { AttachFile, Close, DeleteOutline, Image } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
import { useFileUpload } from '../../hooks/useFileUpload'
|
||||
|
||||
const AttachmentPickerField = ({
|
||||
attachments = [],
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
entityType = 'chore_attachment',
|
||||
entityId,
|
||||
draftId,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
const { uploadFile } = useFileUpload({ entityType, entityId, draftId })
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleEscape = e => {
|
||||
if (e.key === 'Escape') setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen])
|
||||
|
||||
const handleAddFile = () => {
|
||||
const input = document.createElement('input')
|
||||
input.setAttribute('type', 'file')
|
||||
input.setAttribute('accept', 'image/*')
|
||||
input.click()
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const url = await uploadFile(file)
|
||||
if (url) {
|
||||
onChange([...attachments, { url, name: file.name }])
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = index => {
|
||||
const updated = attachments.filter((_, i) => i !== index)
|
||||
onChange(updated)
|
||||
if (updated.length === 0) setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleClear = e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const isEmpty = attachments.length === 0
|
||||
const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
size='sm'
|
||||
variant={isEmpty ? 'outlined' : 'soft'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
sx={{
|
||||
borderRadius: '128px',
|
||||
minHeight: 40,
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{isUploading ? (
|
||||
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '16px' }} />
|
||||
) : (
|
||||
<AttachFile sx={{ fontSize: '20px' }} />
|
||||
)}
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 180 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{isEmpty
|
||||
? 'Attachments'
|
||||
: `${attachments.length} file${attachments.length !== 1 ? 's' : ''}`}
|
||||
</Typography>
|
||||
</Button>
|
||||
{!isEmpty && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={handleClear}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': { bgcolor: 'danger.softBg' },
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isOpen && (
|
||||
<Popper
|
||||
open={isOpen}
|
||||
anchorEl={buttonRef.current}
|
||||
placement='top-start'
|
||||
modifiers={[
|
||||
{ name: 'offset', options: { offset: [0, 8] } },
|
||||
{
|
||||
name: 'flip',
|
||||
options: { fallbackPlacements: ['bottom-start', 'top-start'] },
|
||||
},
|
||||
]}
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
|
||||
<Sheet
|
||||
variant='outlined'
|
||||
sx={{
|
||||
minWidth: 240,
|
||||
maxWidth: 320,
|
||||
p: 1,
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'lg',
|
||||
bgcolor: 'background.popup',
|
||||
}}
|
||||
>
|
||||
{attachments.length > 0 && (
|
||||
<Box sx={{ mb: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{attachments.map((attachment, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 0.5,
|
||||
borderRadius: 'sm',
|
||||
'&:hover': { bgcolor: 'background.level1' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component='img'
|
||||
src={attachment.url}
|
||||
alt={attachment.name}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 'sm',
|
||||
flexShrink: 0,
|
||||
bgcolor: 'background.level2',
|
||||
}}
|
||||
onError={e => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextSibling.style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'none',
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 'sm',
|
||||
bgcolor: 'background.level2',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Image sx={{ fontSize: 20, color: 'text.tertiary' }} />
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{attachment.name}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='danger'
|
||||
onClick={() => handleRemove(index)}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
<DeleteOutline sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
isUploading ? (
|
||||
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '14px' }} />
|
||||
) : (
|
||||
<AttachFile sx={{ fontSize: 16 }} />
|
||||
)
|
||||
}
|
||||
onClick={handleAddFile}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? 'Uploading…' : 'Add image'}
|
||||
</Button>
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AttachmentPickerField
|
||||
253
src/views/components/BaseOptionPicker.jsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Sheet, Typography } from '@mui/joy'
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const BaseOptionPicker = ({
|
||||
items = [],
|
||||
value = null,
|
||||
values = [],
|
||||
multiple = false,
|
||||
onChange,
|
||||
onValuesChange,
|
||||
emptyDisplay = 'icon',
|
||||
emptyLabel = 'Select',
|
||||
placement = 'top-start',
|
||||
menuMinWidth = 180,
|
||||
menuMaxHeight = 280,
|
||||
getItemValue = item => item.id,
|
||||
getItemLabel = item => item.label,
|
||||
renderItemStart,
|
||||
renderTriggerIcon,
|
||||
getItemColor,
|
||||
getTriggerText,
|
||||
onClear,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleEscape = event => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const selectedItems = useMemo(() => {
|
||||
if (multiple) {
|
||||
const selectedSet = new Set(values)
|
||||
return items.filter(item => selectedSet.has(getItemValue(item)))
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) return []
|
||||
return items.filter(item => getItemValue(item) === value)
|
||||
}, [items, multiple, value, values, getItemValue])
|
||||
|
||||
const isEmpty = selectedItems.length === 0
|
||||
const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text'
|
||||
|
||||
const triggerText = getTriggerText
|
||||
? getTriggerText({ selectedItems, isEmpty })
|
||||
: isEmpty
|
||||
? emptyLabel
|
||||
: getItemLabel(selectedItems[0])
|
||||
|
||||
const triggerColor = isEmpty
|
||||
? undefined
|
||||
: getItemColor
|
||||
? getItemColor(selectedItems[0])
|
||||
: undefined
|
||||
|
||||
const handleSelect = selectedValue => {
|
||||
if (multiple) {
|
||||
const selectedSet = new Set(values)
|
||||
if (selectedSet.has(selectedValue)) {
|
||||
selectedSet.delete(selectedValue)
|
||||
} else {
|
||||
selectedSet.add(selectedValue)
|
||||
}
|
||||
onValuesChange?.(Array.from(selectedSet))
|
||||
return
|
||||
}
|
||||
|
||||
onChange?.(selectedValue)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const isSelected = item => {
|
||||
const optionValue = getItemValue(item)
|
||||
if (multiple) {
|
||||
return values.includes(optionValue)
|
||||
}
|
||||
return value === optionValue
|
||||
}
|
||||
|
||||
const handleClear = e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
size={'sm'}
|
||||
variant={isEmpty ? 'outlined' : 'soft'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
sx={{
|
||||
borderRadius: '128px',
|
||||
minHeight: 40,
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
backgroundColor: triggerColor ? `${triggerColor}20` : undefined,
|
||||
borderColor: triggerColor || undefined,
|
||||
color: triggerColor || undefined,
|
||||
'&:hover': {
|
||||
backgroundColor: triggerColor ? `${triggerColor}28` : undefined,
|
||||
borderColor: triggerColor || undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{renderTriggerIcon?.({ selectedItems, isEmpty })}
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 180 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{triggerText}
|
||||
</Typography>
|
||||
</Button>
|
||||
{!isEmpty && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={handleClear}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': {
|
||||
bgcolor: 'danger.softBg',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isOpen && (
|
||||
<Popper
|
||||
open={isOpen}
|
||||
anchorEl={buttonRef.current}
|
||||
placement={placement}
|
||||
modifiers={[
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 8],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'flip',
|
||||
options: {
|
||||
fallbackPlacements: ['bottom-start', 'top-start'],
|
||||
},
|
||||
},
|
||||
]}
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
|
||||
<Sheet
|
||||
variant='outlined'
|
||||
sx={{
|
||||
minWidth: menuMinWidth,
|
||||
maxHeight: menuMaxHeight,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
p: 0.75,
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'lg',
|
||||
bgcolor: 'background.popup',
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const optionValue = getItemValue(item)
|
||||
const selected = isSelected(item)
|
||||
const itemColor = getItemColor ? getItemColor(item) : undefined
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={optionValue ?? index}
|
||||
variant={selected ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
onClick={() => handleSelect(optionValue)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
mb: index === items.length - 1 ? 0 : 0.5,
|
||||
color: selected
|
||||
? itemColor || 'text.primary'
|
||||
: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{renderItemStart?.({ item, selected })}
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{getItemLabel(item)}
|
||||
</Typography>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BaseOptionPicker
|
||||