From 439ec86722c87278d9c1375492308e609361c527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Mon, 19 Jan 2026 19:15:26 +0100 Subject: [PATCH] chore: Simplify IndexedDB sync by moving to Inertia shared props (#63) This PR simplifies the IndexedDB synchronization mechanism by removing individual sync controllers and services, and instead using Inertia.js shared props to provide data globally across the application. ## Benefits 1. **Simplified Architecture**: Removed complex sync logic for static/semi-static data (accounts, categories, banks, labels, automation rules) 2. **Better Performance**: Data is now shared via Inertia props, eliminating unnecessary API calls and IndexedDB operations 3. **Reduced Complexity**: Significantly reduced codebase size (~2000 lines removed) 4. **Better UX**: Data is immediately available on page load without waiting for sync operations 5. **Maintainability**: Fewer moving parts means easier to maintain and debug ## Migration Notes - Transaction syncing still uses IndexedDB for offline support - All other data (accounts, categories, banks, labels, automation rules) is now fetched via Inertia shared props - Components automatically receive updated data on navigation without manual sync operations --- .env.example | 3 +- Dockerfile | 10 +- .../Controllers/AccountBalanceController.php | 27 + .../Controllers/Api/ImportDataController.php | 34 ++ .../Settings/AutomationRuleController.php | 2 +- .../Controllers/Settings/BankController.php | 19 + .../Sync/AccountBalanceSyncController.php | 84 --- .../Sync/AccountSyncController.php | 28 - .../Sync/AutomationRuleSyncController.php | 23 - .../Controllers/Sync/BankSyncController.php | 29 - .../Sync/CategorySyncController.php | 26 - .../Controllers/Sync/LabelSyncController.php | 26 - .../Controllers/TransactionController.php | 7 + app/Http/Middleware/HandleInertiaRequests.php | 17 + app/Models/User.php | 10 + bun.lock | 1 + public/setup.sh | 148 ++++- .../js/components/accounts/balances-modal.tsx | 10 +- .../js/components/accounts/bank-combobox.tsx | 16 +- .../accounts/create-account-dialog.tsx | 6 +- .../accounts/delete-account-dialog.tsx | 4 +- .../accounts/import-balances-drawer.tsx | 62 +- .../import-balance-step-account.tsx | 32 +- .../accounts/update-balance-dialog.tsx | 8 +- .../js/components/app-sidebar-header.tsx | 2 - .../automation-rules-dialog.tsx | 61 +- .../create-automation-rule-dialog.tsx | 38 +- .../delete-automation-rule-dialog.tsx | 6 +- .../edit-automation-rule-dialog.tsx | 33 +- .../categories/create-category-dialog.tsx | 4 +- .../categories/delete-category-dialog.tsx | 4 +- .../categories/edit-category-dialog.tsx | 4 +- .../js/components/encryption-key-button.tsx | 2 +- .../components/labels/create-label-dialog.tsx | 4 +- .../components/labels/delete-label-dialog.tsx | 4 +- .../components/labels/edit-label-dialog.tsx | 4 +- .../onboarding/step-import-transactions.tsx | 14 + .../js/components/onboarding/step-welcome.tsx | 26 +- .../components/pending-operations-dialog.tsx | 136 ----- .../js/components/shared/label-combobox.tsx | 31 +- .../js/components/sync-status-button.tsx | 113 +--- .../transactions/edit-transaction-dialog.tsx | 103 ++-- .../transactions/import-step-account.tsx | 37 +- .../import-transactions-button.tsx | 53 +- .../import-transactions-drawer.tsx | 84 +-- .../transactions/transaction-filters.tsx | 36 +- .../transactions/transaction-list.tsx | 31 +- resources/js/contexts/sync-context.tsx | 132 +---- .../use-re-evaluate-all-transactions.tsx | 8 +- resources/js/lib/db-migration-helper.ts | 43 -- resources/js/lib/dexie-db.ts | 102 ++-- resources/js/lib/sync-manager.ts | 248 ++------ resources/js/lib/user-session-storage.ts | 74 --- resources/js/pages/Accounts/Show.tsx | 1 + resources/js/pages/auth/register.tsx | 4 +- resources/js/pages/settings/accounts.tsx | 22 +- .../js/pages/settings/automation-rules.tsx | 62 +- resources/js/pages/settings/categories.tsx | 6 +- resources/js/pages/settings/labels.tsx | 6 +- .../js/pages/transactions/categorize.tsx | 18 +- resources/js/pages/transactions/index.tsx | 38 +- resources/js/services/account-balance-sync.ts | 129 ---- resources/js/services/account-sync.ts | 50 -- resources/js/services/automation-rule-sync.ts | 71 --- resources/js/services/bank-sync.ts | 44 -- resources/js/services/category-sync.ts | 50 -- resources/js/services/label-sync.ts | 102 ---- resources/js/services/transaction-sync.ts | 555 ++++-------------- routes/api.php | 23 +- routes/settings.php | 1 + tests/Browser/BankAccountsTest.php | 12 +- .../AccountBalanceSyncControllerTest.php | 291 --------- tests/Feature/AutomationRuleTest.php | 18 +- tests/Feature/IdorVulnerabilityTest.php | 187 ------ tests/Feature/Sync/AccountSyncTest.php | 80 --- tests/Feature/Sync/BankSyncTest.php | 57 -- tests/Feature/Sync/CategorySyncTest.php | 54 -- tests/Feature/Sync/LabelSyncTest.php | 54 -- tests/Pest.php | 2 + tests/TestCase.php | 10 +- 80 files changed, 1083 insertions(+), 2933 deletions(-) create mode 100644 app/Http/Controllers/Api/ImportDataController.php delete mode 100644 app/Http/Controllers/Sync/AccountBalanceSyncController.php delete mode 100644 app/Http/Controllers/Sync/AccountSyncController.php delete mode 100644 app/Http/Controllers/Sync/AutomationRuleSyncController.php delete mode 100644 app/Http/Controllers/Sync/BankSyncController.php delete mode 100644 app/Http/Controllers/Sync/CategorySyncController.php delete mode 100644 app/Http/Controllers/Sync/LabelSyncController.php delete mode 100644 resources/js/components/pending-operations-dialog.tsx delete mode 100644 resources/js/lib/db-migration-helper.ts delete mode 100644 resources/js/lib/user-session-storage.ts delete mode 100644 resources/js/services/account-balance-sync.ts delete mode 100644 resources/js/services/account-sync.ts delete mode 100644 resources/js/services/automation-rule-sync.ts delete mode 100644 resources/js/services/bank-sync.ts delete mode 100644 resources/js/services/category-sync.ts delete mode 100644 resources/js/services/label-sync.ts delete mode 100644 tests/Feature/AccountBalanceSyncControllerTest.php delete mode 100644 tests/Feature/Sync/AccountSyncTest.php delete mode 100644 tests/Feature/Sync/BankSyncTest.php delete mode 100644 tests/Feature/Sync/CategorySyncTest.php delete mode 100644 tests/Feature/Sync/LabelSyncTest.php diff --git a/.env.example b/.env.example index 94cd0a87..938bd192 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -APP_NAME=Laravel +APP_NAME="Whisper Money" APP_ENV=local APP_KEY= APP_DEBUG=true @@ -42,7 +42,6 @@ CACHE_STORE=database MEMCACHED_HOST=127.0.0.1 -REDIS_CLIENT=phpredis REDIS_HOST=redis REDIS_PASSWORD=null REDIS_PORT=6379 diff --git a/Dockerfile b/Dockerfile index e7a9e126..e780d90f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,12 +15,20 @@ RUN apt-get update && apt-get install -y \ netcat-openbsd \ && rm -rf /var/lib/apt/lists/* -RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip intl +RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip intl sockets RUN pecl install redis \ && docker-php-ext-enable redis COPY --from=composer:latest /usr/bin/composer /usr/bin/composer +# Install Bun +RUN curl -fsSL https://bun.com/install | bash +ENV PATH="/root/.bun/bin:${PATH}" + WORKDIR /app +# Install Playwright browsers for Pest browser tests +# Note: This requires node_modules to be installed first, so it's done at runtime +# or you can copy package files and run: bun install && bunx playwright install --with-deps chromium + CMD ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8000"] diff --git a/app/Http/Controllers/AccountBalanceController.php b/app/Http/Controllers/AccountBalanceController.php index 8d61e58b..1941d68f 100644 --- a/app/Http/Controllers/AccountBalanceController.php +++ b/app/Http/Controllers/AccountBalanceController.php @@ -26,6 +26,33 @@ class AccountBalanceController extends Controller return response()->json($balances); } + /** + * Store a new balance for an account. + */ + public function store(Account $account): JsonResponse + { + $this->authorize('update', $account); + + $validated = request()->validate([ + 'balance' => 'required|numeric', + 'balance_date' => 'required|date', + ]); + + $balance = AccountBalance::updateOrCreate( + [ + 'account_id' => $account->id, + 'balance_date' => $validated['balance_date'], + ], + [ + 'balance' => $validated['balance'], + ] + ); + + return response()->json([ + 'data' => $balance, + ], 201); + } + /** * Update or create the current balance for an account. */ diff --git a/app/Http/Controllers/Api/ImportDataController.php b/app/Http/Controllers/Api/ImportDataController.php new file mode 100644 index 00000000..a519da52 --- /dev/null +++ b/app/Http/Controllers/Api/ImportDataController.php @@ -0,0 +1,34 @@ +user(); + + return response()->json([ + 'accounts' => $user->accounts() + ->with('bank:id,name,logo') + ->orderBy('name') + ->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']), + 'categories' => $user->categories() + ->orderBy('name') + ->get(['id', 'name', 'icon', 'color']), + 'banks' => $user->banks() + ->orderBy('name') + ->get(['id', 'name', 'logo']), + 'automationRules' => $user->automationRules() + ->with('category:id,name,icon,color') + ->orderBy('priority') + ->get(), + ]); + } +} diff --git a/app/Http/Controllers/Settings/AutomationRuleController.php b/app/Http/Controllers/Settings/AutomationRuleController.php index 74bfac2a..61b8c9ac 100644 --- a/app/Http/Controllers/Settings/AutomationRuleController.php +++ b/app/Http/Controllers/Settings/AutomationRuleController.php @@ -27,7 +27,7 @@ class AutomationRuleController extends Controller ->get(['id', 'title', 'priority', 'rules_json', 'action_category_id', 'action_note', 'action_note_iv']); return Inertia::render('settings/automation-rules', [ - 'rules' => $rules, + 'automationRules' => $rules, ]); } diff --git a/app/Http/Controllers/Settings/BankController.php b/app/Http/Controllers/Settings/BankController.php index 79e74130..8f657bca 100644 --- a/app/Http/Controllers/Settings/BankController.php +++ b/app/Http/Controllers/Settings/BankController.php @@ -6,10 +6,29 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Settings\StoreBankRequest; use App\Models\Bank; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; class BankController extends Controller { + public function index(Request $request): JsonResponse + { + $query = Bank::query() + ->where(function ($q) { + $q->whereNull('user_id') + ->orWhere('user_id', auth()->id()); + }); + + if ($request->has('search')) { + $search = $request->input('search'); + $query->where('name', 'like', "%{$search}%"); + } + + $banks = $query->orderBy('name')->limit(20)->get(); + + return response()->json(['data' => $banks]); + } + public function store(StoreBankRequest $request): JsonResponse { $data = [ diff --git a/app/Http/Controllers/Sync/AccountBalanceSyncController.php b/app/Http/Controllers/Sync/AccountBalanceSyncController.php deleted file mode 100644 index bd90bfc7..00000000 --- a/app/Http/Controllers/Sync/AccountBalanceSyncController.php +++ /dev/null @@ -1,84 +0,0 @@ -whereHas('account', function ($q) use ($request) { - $q->where('user_id', $request->user()->id); - }); - - if ($request->has('since')) { - $query->where('updated_at', '>', $request->input('since')); - } - - $balances = $query - ->orderBy('balance_date', 'desc') - ->orderBy('updated_at', 'desc') - ->get(); - - return response()->json([ - 'data' => $balances, - ]); - } - - public function store(StoreAccountBalanceRequest $request): JsonResponse - { - $data = $request->validated(); - - $existing = AccountBalance::where('account_id', $data['account_id']) - ->where('balance_date', $data['balance_date']) - ->first(); - - if ($existing) { - $existing->update(['balance' => $data['balance']]); - $balance = $existing; - $wasRecentlyCreated = false; - } else { - $balance = new AccountBalance([ - 'account_id' => $data['account_id'], - 'balance_date' => $data['balance_date'], - 'balance' => $data['balance'], - ]); - - if (isset($data['id'])) { - $balance->id = $data['id']; - $balance->exists = false; - } - - $balance->save(); - $wasRecentlyCreated = true; - } - - return response()->json([ - 'data' => $balance->fresh(), - ], $wasRecentlyCreated ? 201 : 200); - } - - public function update(StoreAccountBalanceRequest $request, AccountBalance $accountBalance): JsonResponse - { - $accountBalance->account->loadMissing('user'); - - if ($accountBalance->account->user_id !== $request->user()->id) { - abort(403, 'Unauthorized'); - } - - $data = $request->validated(); - unset($data['id']); - - $accountBalance->update($data); - - return response()->json([ - 'data' => $accountBalance->fresh(), - ]); - } -} diff --git a/app/Http/Controllers/Sync/AccountSyncController.php b/app/Http/Controllers/Sync/AccountSyncController.php deleted file mode 100644 index 07aa6b0c..00000000 --- a/app/Http/Controllers/Sync/AccountSyncController.php +++ /dev/null @@ -1,28 +0,0 @@ -with('bank') - ->where('user_id', $request->user()->id); - - if ($request->has('since')) { - $query->where('updated_at', '>', $request->input('since')); - } - - $accounts = $query->orderBy('updated_at', 'asc')->get(); - - return response()->json([ - 'data' => $accounts, - ]); - } -} diff --git a/app/Http/Controllers/Sync/AutomationRuleSyncController.php b/app/Http/Controllers/Sync/AutomationRuleSyncController.php deleted file mode 100644 index a56741fd..00000000 --- a/app/Http/Controllers/Sync/AutomationRuleSyncController.php +++ /dev/null @@ -1,23 +0,0 @@ -user() - ->automationRules() - ->with(['category:id,name,icon,color', 'labels:id,name,color']) - ->orderBy('priority') - ->get(); - - return response()->json($rules); - } -} diff --git a/app/Http/Controllers/Sync/BankSyncController.php b/app/Http/Controllers/Sync/BankSyncController.php deleted file mode 100644 index 2980df14..00000000 --- a/app/Http/Controllers/Sync/BankSyncController.php +++ /dev/null @@ -1,29 +0,0 @@ -where(function ($q) use ($request) { - $q->whereNull('user_id') - ->orWhere('user_id', $request->user()->id); - }); - - if ($request->has('since')) { - $query->where('updated_at', '>', $request->input('since')); - } - - $banks = $query->orderBy('updated_at', 'asc')->get(); - - return response()->json([ - 'data' => $banks, - ]); - } -} diff --git a/app/Http/Controllers/Sync/CategorySyncController.php b/app/Http/Controllers/Sync/CategorySyncController.php deleted file mode 100644 index f4677dd4..00000000 --- a/app/Http/Controllers/Sync/CategorySyncController.php +++ /dev/null @@ -1,26 +0,0 @@ -where('user_id', $request->user()->id); - - if ($request->has('since')) { - $query->where('updated_at', '>', $request->input('since')); - } - - $categories = $query->orderBy('name', 'asc')->get(); - - return response()->json([ - 'data' => $categories, - ]); - } -} diff --git a/app/Http/Controllers/Sync/LabelSyncController.php b/app/Http/Controllers/Sync/LabelSyncController.php deleted file mode 100644 index 8ad10421..00000000 --- a/app/Http/Controllers/Sync/LabelSyncController.php +++ /dev/null @@ -1,26 +0,0 @@ -where('user_id', $request->user()->id); - - if ($request->has('since')) { - $query->where('updated_at', '>', $request->input('since')); - } - - $labels = $query->orderBy('name', 'asc')->get(); - - return response()->json([ - 'data' => $labels, - ]); - } -} diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index 70965921..2bb81dce 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -6,6 +6,7 @@ use App\Http\Requests\BulkUpdateTransactionsRequest; use App\Http\Requests\StoreTransactionRequest; use App\Http\Requests\UpdateTransactionRequest; use App\Models\Account; +use App\Models\AutomationRule; use App\Models\Bank; use App\Models\Category; use App\Models\Label; @@ -48,11 +49,17 @@ class TransactionController extends Controller ->orderBy('name') ->get(['id', 'name', 'color']); + $automationRules = AutomationRule::query() + ->where('user_id', $user->id) + ->orderBy('priority') + ->get(); + return Inertia::render('transactions/index', [ 'categories' => $categories, 'accounts' => $accounts, 'banks' => $banks, 'labels' => $labels, + 'automationRules' => $automationRules, ]); } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 1cf45922..56e71035 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -69,6 +69,23 @@ class HandleInertiaRequests extends Middleware 'features' => [ 'cashflow' => true, ], + 'accounts' => fn () => $user ? $user->accounts() + ->with('bank:id,name,logo') + ->orderBy('name') + ->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']) : [], + 'categories' => fn () => $user ? $user->categories() + ->orderBy('name') + ->get(['id', 'name', 'icon', 'color']) : [], + 'banks' => fn () => $user ? $user->banks() + ->orderBy('name') + ->get(['id', 'name', 'logo']) : [], + 'automationRules' => fn () => $user ? $user->automationRules() + ->with('category:id,name,icon,color') + ->orderBy('priority') + ->get() : [], + 'labels' => fn () => $user ? $user->labels() + ->orderBy('name') + ->get(['id', 'name', 'color']) : [], ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index ace20721..9039ee54 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Enums\DripEmailType; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -84,6 +85,15 @@ class User extends Authenticatable return $this->hasMany(Category::class); } + public function banks(): HasMany + { + return $this->hasMany(Bank::class) + ->where(function (Builder $query) { + $query->whereNull('user_id') + ->orWhere('banks.user_id', $this->id); + }); + } + public function automationRules(): HasMany { return $this->hasMany(AutomationRule::class); diff --git a/bun.lock b/bun.lock index 28cec3ab..9a30fc8d 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "dependencies": { diff --git a/public/setup.sh b/public/setup.sh index f2d0d1bf..a0e046b5 100755 --- a/public/setup.sh +++ b/public/setup.sh @@ -790,6 +790,129 @@ stop_services() { echo -e "${GREEN}Services stopped.${NC}" } +# Development mode - enable/disable +dev_mode() { + local action="${1:-toggle}" + + case "$action" in + on | start | enable) + echo -e "${BLUE}Enabling development mode...${NC}" + echo "" + + # Update .env to enable DEV_MODE + if grep -q "^DEV_MODE=" .env; then + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' 's/^DEV_MODE=.*/DEV_MODE=true/' .env + else + sed -i 's/^DEV_MODE=.*/DEV_MODE=true/' .env + fi + else + echo "DEV_MODE=true" >>.env + fi + + # Restart PHP container to pick up the change + echo -e "${YELLOW}Restarting PHP container...${NC}" + docker compose restart php + + # Wait for it to be ready + sleep 3 + wait_for_service "php" + + echo "" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${GREEN} ✓ Development mode enabled!${NC}" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo -e "${BLUE}PHP container is now running:${NC}" + echo -e " • ${GREEN}php artisan serve${NC} - Development server" + echo -e " • ${GREEN}php artisan queue:listen${NC} - Queue worker" + echo -e " • ${GREEN}php artisan pail${NC} - Log viewer" + echo "" + echo -e "${YELLOW}To start Vite for hot-reload, run in a separate terminal:${NC}" + echo -e " ${GREEN}bun run dev${NC}" + echo "" + echo -e "${BLUE}View PHP logs:${NC}" + echo -e " ${YELLOW}whispermoney logs${NC}" + echo "" + echo -e "${BLUE}To disable development mode:${NC}" + echo -e " ${YELLOW}whispermoney dev off${NC}" + echo "" + ;; + + off | stop | disable) + echo -e "${BLUE}Disabling development mode...${NC}" + echo "" + + # Update .env to disable DEV_MODE + if grep -q "^DEV_MODE=" .env; then + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' 's/^DEV_MODE=.*/DEV_MODE=false/' .env + else + sed -i 's/^DEV_MODE=.*/DEV_MODE=false/' .env + fi + fi + + # Restart PHP container + echo -e "${YELLOW}Restarting PHP container...${NC}" + docker compose restart php + + # Wait for it to be ready + sleep 3 + wait_for_service "php" + + echo "" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${GREEN} ✓ Development mode disabled!${NC}" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo -e "${BLUE}PHP container is now running in production mode.${NC}" + echo "" + ;; + + status) + if [ ! -f .env ]; then + echo -e "${RED}.env file not found${NC}" + exit 1 + fi + + if grep -q "^DEV_MODE=true" .env 2>/dev/null; then + echo -e "${GREEN}Development mode: ${GREEN}ENABLED${NC}" + echo "" + echo -e "${BLUE}Running processes in PHP container:${NC}" + echo -e " • php artisan serve" + echo -e " • php artisan queue:listen" + echo -e " • php artisan pail" + else + echo -e "${BLUE}Development mode: ${YELLOW}DISABLED${NC}" + echo "" + echo -e "${BLUE}To enable:${NC}" + echo -e " ${YELLOW}whispermoney dev on${NC}" + fi + echo "" + ;; + + *) + echo -e "${RED}Invalid dev mode action: $action${NC}" + echo "" + echo -e "${YELLOW}Usage:${NC}" + echo -e " ${GREEN}whispermoney dev on${NC} - Enable development mode" + echo -e " ${GREEN}whispermoney dev off${NC} - Disable development mode" + echo -e " ${GREEN}whispermoney dev status${NC} - Check development mode status" + echo "" + exit 1 + ;; + esac +} + +# Show logs +show_logs() { + local service="${1:-php}" + + echo -e "${BLUE}Showing logs for ${service} (Ctrl+C to exit)...${NC}" + echo "" + docker compose logs -f "$service" +} + # Install function install() { print_header @@ -1237,19 +1360,34 @@ main() { upgrade) upgrade ;; + dev) + dev_mode "${2:-on}" + ;; + logs) + show_logs "${2:-php}" + ;; "") ask_for_action ;; *) echo -e "${RED}Unknown command: $1${NC}" echo "" - echo "Usage: $0 [install|start|stop|upgrade]" + echo "Usage: $0 [install|start|stop|upgrade|dev|logs]" echo "" echo "Commands:" - echo " install - Install Whisper Money" - echo " start - Start Docker services" - echo " stop - Stop Docker services" - echo " upgrade - Upgrade Whisper Money" + echo " install - Install Whisper Money" + echo " start - Start Docker services" + echo " stop - Stop Docker services" + echo " upgrade - Upgrade Whisper Money" + echo " dev [on|off] - Enable/disable development mode" + echo " logs [service] - Show logs (default: php)" + echo "" + echo "Examples:" + echo " whispermoney dev on - Enable dev mode (queue, pail)" + echo " whispermoney dev off - Disable dev mode" + echo " whispermoney dev status - Check dev mode status" + echo " whispermoney logs php - Show PHP logs" + echo " whispermoney logs mysql - Show MySQL logs" echo "" echo "If no command is provided, an interactive menu will be shown." exit 1 diff --git a/resources/js/components/accounts/balances-modal.tsx b/resources/js/components/accounts/balances-modal.tsx index 760d77d7..d5535342 100644 --- a/resources/js/components/accounts/balances-modal.tsx +++ b/resources/js/components/accounts/balances-modal.tsx @@ -1,8 +1,8 @@ import { destroy, index, + store, } from '@/actions/App/Http/Controllers/AccountBalanceController'; -import { store } from '@/actions/App/Http/Controllers/Sync/AccountBalanceSyncController'; import { AlertDialog, AlertDialogAction, @@ -33,7 +33,6 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; -import { accountBalanceSyncService } from '@/services/account-balance-sync'; import type { Account, AccountBalance } from '@/types/account'; import { Pencil, Trash2 } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; @@ -135,7 +134,7 @@ export function BalancesModal({ setIsEditSubmitting(true); try { - const response = await fetch(store.url(), { + const response = await fetch(store.url(account.id), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -148,7 +147,6 @@ export function BalancesModal({ Accept: 'application/json', }, body: JSON.stringify({ - account_id: account.id, balance_date: editDate, balance: editAmount, }), @@ -158,8 +156,6 @@ export function BalancesModal({ throw new Error('Failed to update balance'); } - await accountBalanceSyncService.sync(); - setEditingBalance(null); fetchBalances(currentPage); onBalanceChange?.(); @@ -198,8 +194,6 @@ export function BalancesModal({ throw new Error('Failed to delete balance'); } - await accountBalanceSyncService.sync(); - setDeletingBalance(null); fetchBalances(currentPage); onBalanceChange?.(); diff --git a/resources/js/components/accounts/bank-combobox.tsx b/resources/js/components/accounts/bank-combobox.tsx index 67d92c58..60af7a85 100644 --- a/resources/js/components/accounts/bank-combobox.tsx +++ b/resources/js/components/accounts/bank-combobox.tsx @@ -1,3 +1,4 @@ +import { index as indexBanks } from '@/actions/App/Http/Controllers/Settings/BankController'; import { Button } from '@/components/ui/button'; import { Command, @@ -14,7 +15,6 @@ import { PopoverTrigger, } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; -import { bankSyncService } from '@/services/bank-sync'; import { type Bank } from '@/types/account'; import { Check, ChevronsUpDown, Plus } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; @@ -58,7 +58,19 @@ export function BankCombobox({ setIsLoading(true); try { - const results = await bankSyncService.search(query); + const response = await fetch( + indexBanks.url({ query: { search: query } }), + { + headers: { + Accept: 'application/json', + }, + }, + ); + if (!response.ok) { + throw new Error('Failed to search banks'); + } + const data = await response.json(); + const results = data.data || data; bankCache.set(query, results); setBanks(results); } catch (error) { diff --git a/resources/js/components/accounts/create-account-dialog.tsx b/resources/js/components/accounts/create-account-dialog.tsx index 8f38cc9e..f3ce708d 100644 --- a/resources/js/components/accounts/create-account-dialog.tsx +++ b/resources/js/components/accounts/create-account-dialog.tsx @@ -206,7 +206,11 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) { > Cancel - diff --git a/resources/js/components/accounts/delete-account-dialog.tsx b/resources/js/components/accounts/delete-account-dialog.tsx index 3e66afa2..b587c234 100644 --- a/resources/js/components/accounts/delete-account-dialog.tsx +++ b/resources/js/components/accounts/delete-account-dialog.tsx @@ -10,7 +10,6 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { db } from '@/lib/dexie-db'; import { type Account } from '@/types/account'; import { Form, router } from '@inertiajs/react'; import { useState } from 'react'; @@ -73,8 +72,7 @@ export function DeleteAccountDialog({
{ - await db.accounts.delete(account.id); + onSuccess={() => { handleOpenChange(false); if (redirectTo) { router.visit(redirectTo); diff --git a/resources/js/components/accounts/import-balances-drawer.tsx b/resources/js/components/accounts/import-balances-drawer.tsx index e963d59e..343b462e 100644 --- a/resources/js/components/accounts/import-balances-drawer.tsx +++ b/resources/js/components/accounts/import-balances-drawer.tsx @@ -1,3 +1,4 @@ +import { store } from '@/actions/App/Http/Controllers/AccountBalanceController'; import AlertError from '@/components/alert-error'; import { Drawer, @@ -17,8 +18,6 @@ import { parseDate, parseFile, } from '@/lib/file-parser'; -import { accountBalanceSyncService } from '@/services/account-balance-sync'; -import { accountSyncService } from '@/services/account-sync'; import { type Account } from '@/types/account'; import { BalanceImportStep, @@ -39,6 +38,7 @@ import { ImportBalanceStepUpload } from './import-balances/import-balance-step-u interface ImportBalancesDrawerProps { open: boolean; onOpenChange: (open: boolean) => void; + accounts?: Account[]; accountId?: UUID; onSuccess?: () => void; } @@ -55,6 +55,7 @@ interface ImportError { export function ImportBalancesDrawer({ open, onOpenChange, + accounts = [], accountId, onSuccess, }: ImportBalancesDrawerProps) { @@ -89,15 +90,14 @@ export function ImportBalancesDrawer({ useEffect(() => { if (state.selectedAccountId) { - accountSyncService - .getById(state.selectedAccountId) - .then((account) => { - if (account) { - setSelectedAccount(account); - } - }); + const account = accounts.find( + (a) => a.id === state.selectedAccountId, + ); + if (account) { + setSelectedAccount(account); + } } - }, [state.selectedAccountId]); + }, [state.selectedAccountId, accounts]); useEffect(() => { if (!open) { @@ -381,6 +381,13 @@ export function ImportBalancesDrawer({ const BATCH_SIZE = 50; let processedCount = 0; + const xsrfToken = decodeURIComponent( + document.cookie + .split('; ') + .find((row) => row.startsWith('XSRF-TOKEN=')) + ?.split('=')[1] || '', + ); + for (let i = 0; i < state.balances.length; i += BATCH_SIZE) { const batch = state.balances.slice(i, i + BATCH_SIZE); @@ -388,16 +395,33 @@ export function ImportBalancesDrawer({ batch.map(async (balance, batchIndex) => { const rowNumber = i + batchIndex + 1; - const createdBalance = - await accountBalanceSyncService.updateOrCreate( - selectedAccount.id, - balance.balance_date, - balance.balance, - ); + const response = await fetch( + store.url(selectedAccount.id), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-XSRF-TOKEN': xsrfToken, + Accept: 'application/json', + }, + body: JSON.stringify({ + balance_date: balance.balance_date, + balance: balance.balance, + }), + }, + ); + if (!response.ok) { + const data = await response.json(); + throw new Error( + data.message || 'Failed to create balance', + ); + } + + const data = await response.json(); return { success: true, - balance: createdBalance, + balance: data.data, rowNumber, }; }), @@ -462,10 +486,6 @@ export function ImportBalancesDrawer({ } else { toast.error('All balances failed to import'); } - - accountBalanceSyncService.sync().catch((syncError) => { - console.error('Failed to sync balances with backend:', syncError); - }); }; const moveToStep = (step: BalanceImportStep) => { diff --git a/resources/js/components/accounts/import-balances/import-balance-step-account.tsx b/resources/js/components/accounts/import-balances/import-balance-step-account.tsx index df8b1eac..2e515e92 100644 --- a/resources/js/components/accounts/import-balances/import-balance-step-account.tsx +++ b/resources/js/components/accounts/import-balances/import-balance-step-account.tsx @@ -2,51 +2,23 @@ import { EncryptedText } from '@/components/encrypted-text'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { accountSyncService } from '@/services/account-sync'; import { type Account } from '@/types/account'; import type { UUID } from '@/types/uuid'; import { Building2 } from 'lucide-react'; -import { useEffect, useState } from 'react'; interface ImportBalanceStepAccountProps { + accounts?: Account[]; selectedAccountId: UUID | null; onAccountSelect: (accountId: UUID) => void; onNext: () => void; } export function ImportBalanceStepAccount({ + accounts = [], selectedAccountId, onAccountSelect, onNext, }: ImportBalanceStepAccountProps) { - const [accounts, setAccounts] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const loadAccounts = async () => { - try { - const data = await accountSyncService.getAll(); - setAccounts(data); - } catch (error) { - console.error('Failed to load accounts:', error); - } finally { - setLoading(false); - } - }; - - loadAccounts(); - }, []); - - if (loading) { - return ( -
-

- Loading accounts... -

-
- ); - } - if (accounts.length === 0) { return (
diff --git a/resources/js/components/accounts/update-balance-dialog.tsx b/resources/js/components/accounts/update-balance-dialog.tsx index 15e9f901..65657924 100644 --- a/resources/js/components/accounts/update-balance-dialog.tsx +++ b/resources/js/components/accounts/update-balance-dialog.tsx @@ -1,4 +1,4 @@ -import { store } from '@/actions/App/Http/Controllers/Sync/AccountBalanceSyncController'; +import { store } from '@/actions/App/Http/Controllers/AccountBalanceController'; import { AmountInput } from '@/components/ui/amount-input'; import { Button } from '@/components/ui/button'; import { @@ -11,7 +11,6 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { accountBalanceSyncService } from '@/services/account-balance-sync'; import type { Account } from '@/types/account'; import { useState } from 'react'; @@ -53,7 +52,7 @@ export function UpdateBalanceDialog({ setError(null); try { - const response = await fetch(store.url(), { + const response = await fetch(store.url(account.id), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -66,7 +65,6 @@ export function UpdateBalanceDialog({ Accept: 'application/json', }, body: JSON.stringify({ - account_id: account.id, balance_date: date, balance: balance, }), @@ -77,8 +75,6 @@ export function UpdateBalanceDialog({ throw new Error(data.message || 'Failed to update balance'); } - await accountBalanceSyncService.sync(); - handleOpenChange(false); onSuccess?.(); } catch (err) { diff --git a/resources/js/components/app-sidebar-header.tsx b/resources/js/components/app-sidebar-header.tsx index c950867d..6aec892f 100644 --- a/resources/js/components/app-sidebar-header.tsx +++ b/resources/js/components/app-sidebar-header.tsx @@ -1,6 +1,5 @@ import { Breadcrumbs } from '@/components/breadcrumbs'; import { EncryptionKeyButton } from '@/components/encryption-key-button'; -import { SyncStatusButton } from '@/components/sync-status-button'; import { ImportTransactionsButton } from '@/components/transactions/import-transactions-button'; import { Separator } from '@/components/ui/separator'; import { SidebarTrigger } from '@/components/ui/sidebar'; @@ -28,7 +27,6 @@ export function AppSidebarHeader({ orientation="vertical" className="data-[orientation=vertical]:h-6" /> - void; } -function AutomationRuleActions({ rule }: { rule: AutomationRule }) { +function AutomationRuleActions({ + rule, + categories, + labels, +}: { + rule: AutomationRule; + categories: Category[]; + labels: Label[]; +}) { const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -70,7 +78,11 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) { <> - @@ -91,6 +103,8 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) { @@ -103,7 +117,15 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) { ); } -function AutomationRuleRow({ row }: { row: Row }) { +function AutomationRuleRow({ + row, + categories, + labels, +}: { + row: Row; + categories: Category[]; + labels: Label[]; +}) { const rule = row.original; const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -147,6 +169,8 @@ function AutomationRuleRow({ row }: { row: Row }) { @@ -164,8 +188,13 @@ export function AutomationRulesDialog({ onOpenChange, }: AutomationRulesDialogProps) { const { isKeySet } = useEncryptionKey(); - const rawRules = - useLiveQuery(() => db.automation_rules.toArray(), []) || []; + const { automationRules: rawRules } = usePage<{ + automationRules: AutomationRule[]; + }>().props; + + // Get categories and labels from globally shared Inertia data + const categories = usePage().props.categories as Category[]; + const labels = usePage().props.labels as Label[]; const rules = useMemo( () => rawRules.map((rule) => ({ @@ -278,7 +307,13 @@ export function AutomationRulesDialog({ { id: 'actions', enableHiding: false, - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, ]; @@ -325,7 +360,11 @@ export function AutomationRulesDialog({ } className="max-w-sm" /> - +
@@ -358,6 +397,8 @@ export function AutomationRulesDialog({ )) ) : ( diff --git a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx index 9f00137d..d6ec528f 100644 --- a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx +++ b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx @@ -19,26 +19,25 @@ import { isValidRuleStructure, type RuleStructure, } from '@/lib/rule-builder-utils'; -import { automationRuleSyncService } from '@/services/automation-rule-sync'; -import { categorySyncService } from '@/services/category-sync'; -import { labelSyncService } from '@/services/label-sync'; import type { Category } from '@/types/category'; import type { Label } from '@/types/label'; import { router } from '@inertiajs/react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; interface CreateAutomationRuleDialogProps { + categories: Category[]; + labels: Label[]; disabled?: boolean; onSuccess?: () => void; } export function CreateAutomationRuleDialog({ + categories, + labels, disabled = false, onSuccess, }: CreateAutomationRuleDialogProps) { const [open, setOpen] = useState(false); - const [categories, setCategories] = useState([]); - const [labels, setLabels] = useState([]); const [title, setTitle] = useState(''); const [priority, setPriority] = useState('10'); const [ruleStructure, setRuleStructure] = useState({ @@ -50,18 +49,6 @@ export function CreateAutomationRuleDialog({ const [isSubmitting, setIsSubmitting] = useState(false); const [errors, setErrors] = useState>({}); - useEffect(() => { - const loadData = async () => { - const [categoriesData, labelsData] = await Promise.all([ - categorySyncService.getAll(), - labelSyncService.getAll(), - ]); - setCategories(categoriesData); - setLabels(labelsData); - }; - loadData(); - }, []); - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setErrors({}); @@ -107,7 +94,7 @@ export function CreateAutomationRuleDialog({ { preserveState: true, preserveScroll: true, - onSuccess: async () => { + onSuccess: () => { setOpen(false); setTitle(''); setPriority('10'); @@ -118,7 +105,6 @@ export function CreateAutomationRuleDialog({ setCategoryId(''); setSelectedLabelIds([]); setErrors({}); - await automationRuleSyncService.sync(); onSuccess?.(); }, onError: (errors) => { @@ -223,12 +209,6 @@ export function CreateAutomationRuleDialog({ labels={labels} placeholder="Select labels (optional)" allowCreate={true} - onLabelCreated={(newLabel) => { - setLabels((prev) => [ - ...prev, - newLabel, - ]); - }} />
@@ -256,7 +236,11 @@ export function CreateAutomationRuleDialog({ > Cancel - diff --git a/resources/js/components/automation-rules/delete-automation-rule-dialog.tsx b/resources/js/components/automation-rules/delete-automation-rule-dialog.tsx index baf389a8..178d6b87 100644 --- a/resources/js/components/automation-rules/delete-automation-rule-dialog.tsx +++ b/resources/js/components/automation-rules/delete-automation-rule-dialog.tsx @@ -9,7 +9,6 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; -import { automationRuleSyncService } from '@/services/automation-rule-sync'; import type { AutomationRule } from '@/types/automation-rule'; import { router } from '@inertiajs/react'; import { useState } from 'react'; @@ -29,14 +28,13 @@ export function DeleteAutomationRuleDialog({ }: DeleteAutomationRuleDialogProps) { const [isDeleting, setIsDeleting] = useState(false); - const handleDelete = async () => { + const handleDelete = () => { setIsDeleting(true); router.delete(destroy(rule.id).url, { preserveState: true, preserveScroll: true, - onSuccess: async () => { + onSuccess: () => { onOpenChange(false); - await automationRuleSyncService.sync(); onSuccess?.(); }, onFinish: () => { diff --git a/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx b/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx index 8c5b98a3..0d0f70b5 100644 --- a/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx +++ b/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx @@ -19,9 +19,6 @@ import { parseJsonLogic, type RuleStructure, } from '@/lib/rule-builder-utils'; -import { automationRuleSyncService } from '@/services/automation-rule-sync'; -import { categorySyncService } from '@/services/category-sync'; -import { labelSyncService } from '@/services/label-sync'; import type { AutomationRule } from '@/types/automation-rule'; import type { Category } from '@/types/category'; import type { Label as LabelType } from '@/types/label'; @@ -30,6 +27,8 @@ import { useEffect, useState } from 'react'; interface EditAutomationRuleDialogProps { rule: AutomationRule; + categories: Category[]; + labels: LabelType[]; open: boolean; onOpenChange: (open: boolean) => void; onSuccess?: () => void; @@ -37,12 +36,12 @@ interface EditAutomationRuleDialogProps { export function EditAutomationRuleDialog({ rule, + categories, + labels, open, onOpenChange, onSuccess, }: EditAutomationRuleDialogProps) { - const [categories, setCategories] = useState([]); - const [labels, setLabels] = useState([]); const [title, setTitle] = useState(''); const [priority, setPriority] = useState('0'); const [ruleStructure, setRuleStructure] = useState({ @@ -54,18 +53,6 @@ export function EditAutomationRuleDialog({ const [isSubmitting, setIsSubmitting] = useState(false); const [errors, setErrors] = useState>({}); - useEffect(() => { - const loadData = async () => { - const [categoriesData, labelsData] = await Promise.all([ - categorySyncService.getAll(), - labelSyncService.getAll(), - ]); - setCategories(categoriesData); - setLabels(labelsData); - }; - loadData(); - }, []); - useEffect(() => { if (rule && open) { setTitle(rule.title); @@ -123,10 +110,9 @@ export function EditAutomationRuleDialog({ { preserveState: true, preserveScroll: true, - onSuccess: async () => { + onSuccess: () => { onOpenChange(false); setErrors({}); - await automationRuleSyncService.sync(); onSuccess?.(); }, onError: (errors) => { @@ -223,9 +209,6 @@ export function EditAutomationRuleDialog({ labels={labels} placeholder="Select labels (optional)" allowCreate={true} - onLabelCreated={(newLabel) => { - setLabels((prev) => [...prev, newLabel]); - }} /> @@ -252,7 +235,11 @@ export function EditAutomationRuleDialog({ > Cancel - diff --git a/resources/js/components/categories/create-category-dialog.tsx b/resources/js/components/categories/create-category-dialog.tsx index 7fd5ff73..f5a68610 100644 --- a/resources/js/components/categories/create-category-dialog.tsx +++ b/resources/js/components/categories/create-category-dialog.tsx @@ -19,7 +19,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { categorySyncService } from '@/services/category-sync'; import { CATEGORY_COLORS, CATEGORY_ICONS, @@ -53,8 +52,7 @@ export function CreateCategoryDialog({ { - await categorySyncService.sync(); + onSuccess={() => { setOpen(false); onSuccess?.(); }} diff --git a/resources/js/components/categories/delete-category-dialog.tsx b/resources/js/components/categories/delete-category-dialog.tsx index 3b52a298..fffe97f5 100644 --- a/resources/js/components/categories/delete-category-dialog.tsx +++ b/resources/js/components/categories/delete-category-dialog.tsx @@ -8,7 +8,6 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { categorySyncService } from '@/services/category-sync'; import { type Category } from '@/types/category'; import { Form } from '@inertiajs/react'; @@ -37,8 +36,7 @@ export function DeleteCategoryDialog({ { - await categorySyncService.delete(category.id); + onSuccess={() => { onOpenChange(false); onSuccess?.(); }} diff --git a/resources/js/components/categories/edit-category-dialog.tsx b/resources/js/components/categories/edit-category-dialog.tsx index 53385c1b..453ecb36 100644 --- a/resources/js/components/categories/edit-category-dialog.tsx +++ b/resources/js/components/categories/edit-category-dialog.tsx @@ -18,7 +18,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { categorySyncService } from '@/services/category-sync'; import { CATEGORY_COLORS, CATEGORY_ICONS, @@ -57,8 +56,7 @@ export function EditCategoryDialog({ { - await categorySyncService.sync(); + onSuccess={() => { onOpenChange(false); onSuccess?.(); }} diff --git a/resources/js/components/encryption-key-button.tsx b/resources/js/components/encryption-key-button.tsx index 57d2d89a..614e25ff 100644 --- a/resources/js/components/encryption-key-button.tsx +++ b/resources/js/components/encryption-key-button.tsx @@ -33,7 +33,7 @@ export function EncryptionKeyButton() { if (!encryptedMessageData) { fetchEncryptedMessage(); } - }, []); + }, [encryptedMessageData, fetchEncryptedMessage]); function handleClick() { if (isKeySet) { diff --git a/resources/js/components/labels/create-label-dialog.tsx b/resources/js/components/labels/create-label-dialog.tsx index 88acd6be..0d8eb0f3 100644 --- a/resources/js/components/labels/create-label-dialog.tsx +++ b/resources/js/components/labels/create-label-dialog.tsx @@ -18,7 +18,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { labelSyncService } from '@/services/label-sync'; import { getLabelColorClasses, LABEL_COLORS } from '@/types/label'; import { Form } from '@inertiajs/react'; import { useState } from 'react'; @@ -40,8 +39,7 @@ export function CreateLabelDialog({ onSuccess }: { onSuccess?: () => void }) { { - await labelSyncService.sync(); + onSuccess={() => { setOpen(false); onSuccess?.(); }} diff --git a/resources/js/components/labels/delete-label-dialog.tsx b/resources/js/components/labels/delete-label-dialog.tsx index e23920f8..06fea46a 100644 --- a/resources/js/components/labels/delete-label-dialog.tsx +++ b/resources/js/components/labels/delete-label-dialog.tsx @@ -8,7 +8,6 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { labelSyncService } from '@/services/label-sync'; import { type Label } from '@/types/label'; import { Form } from '@inertiajs/react'; @@ -37,8 +36,7 @@ export function DeleteLabelDialog({ { - await labelSyncService.delete(label.id); + onSuccess={() => { onOpenChange(false); onSuccess?.(); }} diff --git a/resources/js/components/labels/edit-label-dialog.tsx b/resources/js/components/labels/edit-label-dialog.tsx index 6af6dbef..74ed1624 100644 --- a/resources/js/components/labels/edit-label-dialog.tsx +++ b/resources/js/components/labels/edit-label-dialog.tsx @@ -17,7 +17,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { labelSyncService } from '@/services/label-sync'; import { getLabelColorClasses, LABEL_COLORS, @@ -49,8 +48,7 @@ export function EditLabelDialog({ { - await labelSyncService.sync(); + onSuccess={() => { onOpenChange(false); onSuccess?.(); }} diff --git a/resources/js/components/onboarding/step-import-transactions.tsx b/resources/js/components/onboarding/step-import-transactions.tsx index 1b095e66..9846f267 100644 --- a/resources/js/components/onboarding/step-import-transactions.tsx +++ b/resources/js/components/onboarding/step-import-transactions.tsx @@ -2,6 +2,10 @@ import { StepHeader } from '@/components/onboarding/step-header'; import { ImportTransactionsDrawer } from '@/components/transactions/import-transactions-drawer'; import { Button } from '@/components/ui/button'; import { CreatedAccount } from '@/hooks/use-onboarding-state'; +import { type Account, type Bank } from '@/types/account'; +import { type AutomationRule } from '@/types/automation-rule'; +import { type Category } from '@/types/category'; +import { usePage } from '@inertiajs/react'; import { ArrowRight, FileSpreadsheet, Upload } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; @@ -16,6 +20,12 @@ export function StepImportTransactions({ }: StepImportTransactionsProps) { const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [hasImported, setHasImported] = useState(false); + const { accounts, categories, banks, automationRules } = usePage<{ + accounts: Account[]; + categories: Category[]; + banks: Bank[]; + automationRules: AutomationRule[]; + }>().props; const handleDrawerClose = (open: boolean) => { setIsDrawerOpen(open); @@ -113,6 +123,10 @@ export function StepImportTransactions({ ); diff --git a/resources/js/components/onboarding/step-welcome.tsx b/resources/js/components/onboarding/step-welcome.tsx index 0fd1b221..e17859f7 100644 --- a/resources/js/components/onboarding/step-welcome.tsx +++ b/resources/js/components/onboarding/step-welcome.tsx @@ -1,30 +1,12 @@ import { StepButton } from '@/components/onboarding/step-button'; import { StepHeader } from '@/components/onboarding/step-header'; -import { bankSyncService } from '@/services/bank-sync'; import { Bird } from 'lucide-react'; -import { useEffect, useState } from 'react'; interface StepWelcomeProps { onContinue: () => void; } export function StepWelcome({ onContinue }: StepWelcomeProps) { - const [isSyncing, setIsSyncing] = useState(true); - - useEffect(() => { - const syncBanks = async () => { - try { - await bankSyncService.sync(); - } catch (error) { - console.error('Failed to sync banks:', error); - } finally { - setIsSyncing(false); - } - }; - - syncBanks(); - }, []); - return (
- +

This will take less than 5 minutes diff --git a/resources/js/components/pending-operations-dialog.tsx b/resources/js/components/pending-operations-dialog.tsx deleted file mode 100644 index 50965be8..00000000 --- a/resources/js/components/pending-operations-dialog.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import type { SyncStatus } from '@/contexts/sync-context'; -import type { PendingChange } from '@/lib/dexie-db'; -import { formatDistanceToNow } from 'date-fns'; -import { RefreshCw } from 'lucide-react'; - -interface PendingOperationsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - operations: PendingChange[]; - onSyncNow: () => void; - syncStatus: SyncStatus; - isOnline: boolean; -} - -function getOperationBadgeClass(operation: PendingChange['operation']): string { - switch (operation) { - case 'create': - return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'; - case 'update': - return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'; - case 'delete': - return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'; - default: - return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200'; - } -} - -function formatDataPreview(data: Record): string { - if (data.id) { - const preview = `ID: ${String(data.id).slice(0, 8)}...`; - if (data.name) { - return `${preview}, Name: ${data.name}`; - } - if (data.description) { - return `${preview}, ${String(data.description).slice(0, 20)}...`; - } - return preview; - } - return JSON.stringify(data).slice(0, 50) + '...'; -} - -export function PendingOperationsDialog({ - open, - onOpenChange, - operations, - onSyncNow, - syncStatus, - isOnline, -}: PendingOperationsDialogProps) { - const isSyncing = syncStatus === 'syncing'; - const canSync = isOnline && !isSyncing; - return ( -

- - - Pending Operations - - These operations are waiting to be synced with the - server. - - -
- {operations.length === 0 ? ( -
- No pending operations -
- ) : ( - - - - Store - Operation - Data - Time - - - - {operations.map((op) => ( - - - {op.store.replace('_', ' ')} - - - - {op.operation} - - - - {formatDataPreview(op.data)} - - - {formatDistanceToNow( - new Date(op.timestamp), - { addSuffix: true }, - )} - - - ))} - -
- )} -
-
- -
-
-
- ); -} diff --git a/resources/js/components/shared/label-combobox.tsx b/resources/js/components/shared/label-combobox.tsx index 8970c857..9d34508b 100644 --- a/resources/js/components/shared/label-combobox.tsx +++ b/resources/js/components/shared/label-combobox.tsx @@ -1,3 +1,4 @@ +import { store } from '@/actions/App/Http/Controllers/Settings/LabelController'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -13,7 +14,6 @@ import { PopoverTrigger, } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; -import { labelSyncService } from '@/services/label-sync'; import { getLabelColorClasses, LABEL_COLORS, type Label } from '@/types/label'; import { Check, ChevronsUpDown, Plus, Tag, X } from 'lucide-react'; import { useState } from 'react'; @@ -80,10 +80,31 @@ export function LabelCombobox({ try { const randomColor = LABEL_COLORS[Math.floor(Math.random() * LABEL_COLORS.length)]; - const newLabel = await labelSyncService.findOrCreate( - inputValue.trim(), - randomColor, - ); + + const response = await fetch(store.url(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-XSRF-TOKEN': decodeURIComponent( + document.cookie + .split('; ') + .find((row) => row.startsWith('XSRF-TOKEN=')) + ?.split('=')[1] || '', + ), + Accept: 'application/json', + }, + body: JSON.stringify({ + name: inputValue.trim(), + color: randomColor, + }), + }); + + if (!response.ok) { + throw new Error('Failed to create label'); + } + + const data = await response.json(); + const newLabel = data.data || data; if (newLabel) { onValueChange([...value, newLabel.id]); diff --git a/resources/js/components/sync-status-button.tsx b/resources/js/components/sync-status-button.tsx index c2d8b4c1..72ae698a 100644 --- a/resources/js/components/sync-status-button.tsx +++ b/resources/js/components/sync-status-button.tsx @@ -9,27 +9,12 @@ import { } from '@/components/ui/dropdown-menu'; import { useSyncContext } from '@/contexts/sync-context'; import { formatDistanceToNow } from 'date-fns'; -import { - CloudAlert, - CloudCheck, - CloudOff, - List, - RefreshCw, -} from 'lucide-react'; +import { CloudAlert, CloudCheck, CloudOff, RefreshCw } from 'lucide-react'; import { useState } from 'react'; -import { PendingOperationsDialog } from './pending-operations-dialog'; export function SyncStatusButton() { - const { - syncStatus, - lastSyncTime, - isOnline, - sync, - error, - pendingOperationsCount, - pendingOperations, - } = useSyncContext(); - const [showPendingDialog, setShowPendingDialog] = useState(false); + const { syncStatus, lastSyncTime, isOnline, sync, error } = + useSyncContext(); const [isMenuOpen, setIsMenuOpen] = useState(false); const getIcon = () => { @@ -73,68 +58,34 @@ export function SyncStatusButton() { }; return ( - <> - - - - - - -
-

- {getStatusText()} -

-

- {pendingOperationsCount} pending{' '} - {pendingOperationsCount === 1 - ? 'operation' - : 'operations'} -

-
-
- - { - e.preventDefault(); - handleSyncNow(); - }} - disabled={syncStatus === 'syncing' || !isOnline} - > - - Sync now - - setShowPendingDialog(true)} - > - - View pending operations - -
-
- - - + + + + + + +

{getStatusText()}

+
+ + { + e.preventDefault(); + handleSyncNow(); + }} + disabled={syncStatus === 'syncing' || !isOnline} + > + + Sync now + +
+
); } diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index 2b94eeb6..0e6b94b5 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -1,3 +1,7 @@ +import { + index as indexBalances, + store as storeBalance, +} from '@/actions/App/Http/Controllers/AccountBalanceController'; import { LabelCombobox } from '@/components/shared/label-combobox'; import { CategorySelect } from '@/components/transactions/category-select'; import { AmountInput } from '@/components/ui/amount-input'; @@ -22,12 +26,11 @@ import { } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { useEncryptionKey } from '@/contexts/encryption-key-context'; +import { useSyncContext } from '@/contexts/sync-context'; import { decrypt, encrypt, importKey } from '@/lib/crypto'; import { getStoredKey } from '@/lib/key-storage'; import { evaluateRulesForNewTransaction } from '@/lib/rule-engine'; import { appendNoteIfNotPresent } from '@/lib/utils'; -import { accountBalanceSyncService } from '@/services/account-balance-sync'; -import { automationRuleSyncService } from '@/services/automation-rule-sync'; import { transactionSyncService } from '@/services/transaction-sync'; import { filterTransactionalAccounts, @@ -48,6 +51,7 @@ interface EditTransactionDialogProps { accounts: Account[]; banks: Bank[]; labels: Label[]; + automationRules?: AutomationRule[]; open: boolean; onOpenChange: (open: boolean) => void; onSuccess: (transaction: DecryptedTransaction) => void; @@ -60,6 +64,7 @@ export function EditTransactionDialog({ accounts, banks, labels, + automationRules = [], open, onOpenChange, onSuccess, @@ -69,6 +74,7 @@ export function EditTransactionDialog({ 'whisper_money_update_balance_on_transaction'; const { isKeySet } = useEncryptionKey(); + const { sync } = useSyncContext(); const [transactionDate, setTransactionDate] = useState(''); const [description, setDescription] = useState(''); const [amount, setAmount] = useState(0); @@ -80,9 +86,6 @@ export function EditTransactionDialog({ const [decryptedAccountNames, setDecryptedAccountNames] = useState< Map >(new Map()); - const [automationRules, setAutomationRules] = useState( - [], - ); const [updateAccountBalance, setUpdateAccountBalance] = useState(() => { if (typeof window !== 'undefined') { const stored = localStorage.getItem(STORAGE_KEY_UPDATE_BALANCE); @@ -161,21 +164,6 @@ export function EditTransactionDialog({ decryptAccountNames(); }, [open, mode, accounts]); - useEffect(() => { - if (!open || mode !== 'create') return; - - async function loadAutomationRules() { - try { - const rules = await automationRuleSyncService.getAll(); - setAutomationRules(rules); - } catch (error) { - console.error('Failed to load automation rules:', error); - } - } - - loadAutomationRules(); - }, [open, mode]); - async function checkAndApplyAutomationRules() { if (mode !== 'create' || automationRules.length === 0) { return { @@ -256,26 +244,60 @@ export function EditTransactionDialog({ transactionDateStr: string, transactionAmount: number, ) { + const xsrfToken = decodeURIComponent( + document.cookie + .split('; ') + .find((row) => row.startsWith('XSRF-TOKEN=')) + ?.split('=')[1] || '', + ); + try { - const allBalances = await accountBalanceSyncService.getAll(); - const accountBalances = allBalances - .filter((b) => b.account_id === accountIdToUpdate) - .sort( - (a, b) => - new Date(b.balance_date).getTime() - - new Date(a.balance_date).getTime(), - ); + // Fetch balances from backend + const balancesResponse = await fetch( + indexBalances.url(accountIdToUpdate), + { + headers: { + Accept: 'application/json', + }, + }, + ); + + if (!balancesResponse.ok) { + throw new Error('Failed to fetch balances'); + } + + const balancesData = await balancesResponse.json(); + const accountBalances = (balancesData.data || []).sort( + (a: { balance_date: string }, b: { balance_date: string }) => + new Date(b.balance_date).getTime() - + new Date(a.balance_date).getTime(), + ); const latestBalance = accountBalances.length > 0 ? accountBalances[0].balance : 0; const newBalance = latestBalance + transactionAmount; - await accountBalanceSyncService.updateOrCreate( - accountIdToUpdate as unknown as number, - transactionDateStr, - newBalance, + // Store new balance via backend + const storeResponse = await fetch( + storeBalance.url(accountIdToUpdate), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-XSRF-TOKEN': xsrfToken, + Accept: 'application/json', + }, + body: JSON.stringify({ + balance_date: transactionDateStr, + balance: newBalance, + }), + }, ); + + if (!storeResponse.ok) { + throw new Error('Failed to store balance'); + } } catch (error) { console.error('Failed to update account balance:', error); toast.error('Transaction created, but failed to update balance'); @@ -408,6 +430,9 @@ export function EditTransactionDialog({ onSuccess(newTransaction); onOpenChange(false); + + // Sync to update IndexedDB + sync(); } else { if (!transaction) { return; @@ -493,6 +518,9 @@ export function EditTransactionDialog({ toast.success('Transaction updated successfully'); onSuccess(updatedTransaction); onOpenChange(false); + + // Sync to update IndexedDB + sync(); } } catch (error) { console.error('Failed to save transaction:', error); @@ -679,7 +707,10 @@ export function EditTransactionDialog({ onValueChange={setAccountId} disabled={isSubmitting} > - + @@ -748,7 +779,11 @@ export function EditTransactionDialog({ > Cancel - @@ -48,10 +79,16 @@ export function ImportTransactionsButton() { - + {importData && ( + + )} ); } diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx index be23e0e7..719139d2 100644 --- a/resources/js/components/transactions/import-transactions-drawer.tsx +++ b/resources/js/components/transactions/import-transactions-drawer.tsx @@ -1,3 +1,4 @@ +import { store as storeBalance } from '@/actions/App/Http/Controllers/AccountBalanceController'; import AlertError from '@/components/alert-error'; import { Drawer, @@ -22,13 +23,10 @@ import { import { getStoredKey } from '@/lib/key-storage'; import { evaluateRulesForNewTransaction } from '@/lib/rule-engine'; import { useTrackEvent } from '@/lib/track-event'; -import { accountBalanceSyncService } from '@/services/account-balance-sync'; -import { accountSyncService } from '@/services/account-sync'; -import { automationRuleSyncService } from '@/services/automation-rule-sync'; -import { bankSyncService } from '@/services/bank-sync'; -import { categorySyncService } from '@/services/category-sync'; import { transactionSyncService } from '@/services/transaction-sync'; -import { type Account } from '@/types/account'; +import { type Account, type Bank } from '@/types/account'; +import { type AutomationRule } from '@/types/automation-rule'; +import { type Category } from '@/types/category'; import { DateFormat, ImportStep, @@ -44,6 +42,10 @@ import { ImportStepPreview } from './import-step-preview'; import { ImportStepUpload } from './import-step-upload'; interface ImportTransactionsDrawerProps { + accounts?: Account[]; + categories?: Category[]; + banks?: Bank[]; + automationRules?: AutomationRule[]; open: boolean; onOpenChange: (open: boolean) => void; } @@ -67,6 +69,10 @@ type ImportFunnelStep = | 'Finish'; export function ImportTransactionsDrawer({ + accounts = [], + categories = [], + banks = [], + automationRules = [], open, onOpenChange, }: ImportTransactionsDrawerProps) { @@ -100,15 +106,14 @@ export function ImportTransactionsDrawer({ useEffect(() => { if (state.selectedAccountId) { - accountSyncService - .getById(state.selectedAccountId) - .then((account) => { - if (account) { - setSelectedAccount(account); - } - }); + const account = accounts.find( + (a) => a.id === state.selectedAccountId, + ); + if (account) { + setSelectedAccount(account); + } } - }, [state.selectedAccountId]); + }, [state.selectedAccountId, accounts]); const trackFunnelStep = useCallback( (step: ImportFunnelStep) => { @@ -281,8 +286,8 @@ export function ImportTransactionsDrawer({ state.dateFormat, ); - const account = await accountSyncService.getById( - state.selectedAccountId!, + const account = accounts.find( + (a) => a.id === state.selectedAccountId, ); if (!account) { @@ -352,11 +357,7 @@ export function ImportTransactionsDrawer({ const errors: ImportError[] = []; const keyString = getStoredKey(); const key = keyString ? await importKey(keyString) : null; - const rules = key ? await automationRuleSyncService.getAll() : []; - - const freshAccounts = await accountSyncService.getAll(); - const freshBanks = await bankSyncService.getAll(); - const freshCategories = await categorySyncService.getAll(); + const rules = key ? automationRules : []; const BATCH_SIZE = 20; let processedCount = 0; @@ -387,9 +388,9 @@ export function ImportTransactionsDrawer({ account_id: selectedAccount.id, }, rules, - freshCategories, - freshAccounts, - freshBanks, + categories, + accounts, + banks, key, ); @@ -488,15 +489,29 @@ export function ImportTransactionsDrawer({ if (balancesToImport.size > 0) { try { - const balanceRecords = Array.from( - balancesToImport.entries(), - ).map(([date, balance]) => ({ - account_id: selectedAccount.id, - balance_date: date, - balance, - })); + const xsrfToken = decodeURIComponent( + document.cookie + .split('; ') + .find((row) => row.startsWith('XSRF-TOKEN=')) + ?.split('=')[1] || '', + ); - await accountBalanceSyncService.createMany(balanceRecords); + const balanceRecords = Array.from(balancesToImport.entries()); + + for (const [date, balance] of balanceRecords) { + await fetch(storeBalance.url(selectedAccount.id), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-XSRF-TOKEN': xsrfToken, + Accept: 'application/json', + }, + body: JSON.stringify({ + balance_date: date, + balance, + }), + }); + } } catch (err) { console.error('Failed to import balances:', err); } @@ -571,10 +586,6 @@ export function ImportTransactionsDrawer({ syncError, ); }); - - accountBalanceSyncService.sync().catch((syncError) => { - console.error('Failed to sync balances with backend:', syncError); - }); }; const handleSelectionChange = (index: number, selected: boolean) => { @@ -645,6 +656,7 @@ export function ImportTransactionsDrawer({ case ImportStep.SelectAccount: return ( { diff --git a/resources/js/components/transactions/transaction-filters.tsx b/resources/js/components/transactions/transaction-filters.tsx index 27dc6969..907fc766 100644 --- a/resources/js/components/transactions/transaction-filters.tsx +++ b/resources/js/components/transactions/transaction-filters.tsx @@ -1,7 +1,7 @@ import { format } from 'date-fns'; import * as Icons from 'lucide-react'; import { Check, ChevronsUpDown, Tag, X } from 'lucide-react'; -import { type ReactNode, useState } from 'react'; +import { type ReactNode, useEffect, useState } from 'react'; import { EncryptedText } from '@/components/encrypted-text'; import { Badge } from '@/components/ui/badge'; @@ -51,10 +51,34 @@ export function TransactionFilters({ const [isOpen, setIsOpen] = useState(false); const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false); const [labelDropdownOpen, setLabelDropdownOpen] = useState(false); + const [searchText, setSearchText] = useState(filters.searchText); const isUncategorizedSelected = filters.categoryIds.includes( UNCATEGORIZED_CATEGORY_ID, ); + // Debounce search text updates + useEffect(() => { + const timer = setTimeout(() => { + if (searchText !== filters.searchText) { + onFiltersChange({ + ...filters, + searchText, + }); + } + }, 300); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchText]); + + // Sync local state when filters change externally + useEffect(() => { + if (filters.searchText !== searchText) { + setSearchText(filters.searchText); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters.searchText]); + function handleCategoryToggle(categoryId: number) { const newCategoryIds = filters.categoryIds.includes(categoryId) ? filters.categoryIds.filter((id) => id !== categoryId) @@ -80,6 +104,7 @@ export function TransactionFilters({ } function clearFilters() { + setSearchText(''); onFiltersChange({ dateFrom: null, dateTo: null, @@ -111,13 +136,8 @@ export function TransactionFilters({ ? 'Search description or notes...' : 'Search disabled (encryption key not set)' } - value={filters.searchText} - onChange={(e) => - onFiltersChange({ - ...filters, - searchText: e.target.value, - }) - } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} disabled={!isKeySet} className="max-w-sm flex-1 md:max-w-full md:min-w-[350px]" /> diff --git a/resources/js/components/transactions/transaction-list.tsx b/resources/js/components/transactions/transaction-list.tsx index 0060fef0..01c76b7e 100644 --- a/resources/js/components/transactions/transaction-list.tsx +++ b/resources/js/components/transactions/transaction-list.tsx @@ -58,9 +58,9 @@ import { db } from '@/lib/dexie-db'; import { getStoredKey } from '@/lib/key-storage'; import { evaluateRules } from '@/lib/rule-engine'; import { appendNoteIfNotPresent } from '@/lib/utils'; -import { automationRuleSyncService } from '@/services/automation-rule-sync'; import { transactionSyncService } from '@/services/transaction-sync'; import { type Account, type Bank } from '@/types/account'; +import { type AutomationRule } from '@/types/automation-rule'; import { type Category } from '@/types/category'; import { type Label } from '@/types/label'; import { @@ -216,6 +216,7 @@ export interface TransactionListProps { accounts: Account[]; banks: Bank[]; labels?: Label[]; + automationRules?: AutomationRule[]; accountId?: UUID; pageSize?: number; hideAccountFilter?: boolean; @@ -230,6 +231,7 @@ export function TransactionList({ accounts, banks, labels: initialLabels = [], + automationRules = [], accountId, pageSize = 25, hideAccountFilter = false, @@ -252,7 +254,7 @@ export function TransactionList({ '', ); - const labels = useLiveQuery(() => db.labels.toArray(), [], initialLabels); + const labels = initialLabels; const [transactions, setTransactions] = useState( [], @@ -664,10 +666,11 @@ export function TransactionList({ consoleDebug('✓ Encryption key found'); const key = await importKey(keyString); - const rules = await automationRuleSyncService.getAll(); - consoleDebug(`Found ${rules.length} automation rules`); + consoleDebug( + `Found ${automationRules.length} automation rules`, + ); - if (rules.length === 0) { + if (automationRules.length === 0) { consoleDebug('❌ No rules to evaluate'); return; } @@ -675,7 +678,7 @@ export function TransactionList({ consoleDebug('Evaluating rules against transaction...'); const result = await evaluateRules( transaction, - rules, + automationRules, categories, accounts, banks, @@ -765,7 +768,14 @@ export function TransactionList({ consoleDebug('=== Re-evaluation complete ==='); } }, - [isKeySet, categories, accounts, banks, updateTransaction], + [ + isKeySet, + categories, + accounts, + banks, + updateTransaction, + automationRules, + ], ); async function handleBulkReEvaluateRules() { @@ -792,10 +802,9 @@ export function TransactionList({ consoleDebug('✓ Encryption key found'); const key = await importKey(keyString); - const rules = await automationRuleSyncService.getAll(); - consoleDebug(`Found ${rules.length} automation rules`); + consoleDebug(`Found ${automationRules.length} automation rules`); - if (rules.length === 0) { + if (automationRules.length === 0) { consoleDebug('❌ No rules to evaluate'); return; } @@ -825,7 +834,7 @@ export function TransactionList({ consoleDebug(`\nEvaluating transaction ${transaction.id}...`); const result = await evaluateRules( transaction, - rules, + automationRules, categories, accounts, banks, diff --git a/resources/js/contexts/sync-context.tsx b/resources/js/contexts/sync-context.tsx index b22a57e5..61e52022 100644 --- a/resources/js/contexts/sync-context.tsx +++ b/resources/js/contexts/sync-context.tsx @@ -1,18 +1,8 @@ import { useOnlineStatus } from '@/hooks/use-online-status'; -import { checkDatabaseVersion } from '@/lib/db-migration-helper'; -import { db, type PendingChange } from '@/lib/dexie-db'; -import { handleUserChange } from '@/lib/user-session-storage'; -import { accountBalanceSyncService } from '@/services/account-balance-sync'; -import { accountSyncService } from '@/services/account-sync'; -import { automationRuleSyncService } from '@/services/automation-rule-sync'; -import { bankSyncService } from '@/services/bank-sync'; -import { categorySyncService } from '@/services/category-sync'; -import { labelSyncService } from '@/services/label-sync'; import { transactionSyncService } from '@/services/transaction-sync'; import type { User } from '@/types/index.d'; import type { Page } from '@inertiajs/core'; import { router } from '@inertiajs/react'; -import { useLiveQuery } from 'dexie-react-hooks'; import { createContext, useCallback, @@ -32,9 +22,6 @@ interface SyncContextType { isAuthenticated: boolean; sync: () => Promise; error: string | null; - pendingOperationsCount: number; - pendingOperations: PendingChange[]; - refreshPendingOperations: () => Promise; } const SyncContext = createContext(undefined); @@ -64,9 +51,6 @@ function formatErrorMessage(error: string): string { return 'Sync failed. Please try again.'; } -const SYNC_INTERVAL = 5 * 60 * 1000; -const AUTO_SYNC_DEBOUNCE = 500; // Auto-sync 500ms after pending changes detected - interface SyncProviderProps { children: ReactNode; initialIsAuthenticated: boolean; @@ -88,16 +72,7 @@ export function SyncProvider({ const [error, setError] = useState(null); const [wasOffline, setWasOffline] = useState(!isOnline); const syncInProgressRef = useRef(false); - const userChangeCheckedRef = useRef(false); - const autoSyncTimeoutRef = useRef(null); - - const pendingOperations = - useLiveQuery(() => db.pending_changes.toArray(), []) || []; - const pendingOperationsCount = pendingOperations.length; - - const refreshPendingOperations = useCallback(async () => { - // No-op: useLiveQuery handles reactivity automatically - }, []); + const lastUserIdRef = useRef(null); useEffect(() => { const unsubscribe = router.on('navigate', (event) => { @@ -146,37 +121,11 @@ export function SyncProvider({ setError(null); try { - const [ - categoriesResult, - accountsResult, - accountBalancesResult, - banksResult, - automationRulesResult, - labelsResult, - transactionsResult, - ] = await Promise.all([ - categorySyncService.sync(), - accountSyncService.sync(), - accountBalanceSyncService.sync(), - bankSyncService.sync(), - automationRuleSyncService.sync(), - labelSyncService.sync(), - transactionSyncService.sync(), - ]); + const result = await transactionSyncService.sync(); - const allErrors = [ - ...categoriesResult.errors, - ...accountsResult.errors, - ...accountBalancesResult.errors, - ...banksResult.errors, - ...automationRulesResult.errors, - ...labelsResult.errors, - ...transactionsResult.errors, - ]; - - if (allErrors.length > 0) { + if (result.errors.length > 0) { const uniqueFormattedErrors = [ - ...new Set(allErrors.map(formatErrorMessage)), + ...new Set(result.errors.map(formatErrorMessage)), ]; setError(uniqueFormattedErrors.join(' ')); setSyncStatus('error'); @@ -210,78 +159,18 @@ export function SyncProvider({ setWasOffline(!isOnline); }, [isAuthenticated, isOnline, wasOffline, sync]); - useEffect(() => { - if (!isOnline || !isAuthenticated) { - return; - } - - const interval = setInterval(() => { - sync(); - }, SYNC_INTERVAL); - - return () => clearInterval(interval); - }, [isAuthenticated, isOnline, sync]); - useEffect(() => { if (!isAuthenticated || !currentUser) { return; } - const checkUserAndSync = async () => { - if (userChangeCheckedRef.current) { - return; - } - - userChangeCheckedRef.current = true; - - const wasCleared = await handleUserChange(currentUser.id); - - if (wasCleared) { - window.location.reload(); - return; - } - - const { needsRefresh, missingStores } = - await checkDatabaseVersion(); - - if (needsRefresh) { - console.warn( - 'Database needs update. Missing stores:', - missingStores, - '\nPlease refresh the page with Ctrl+Shift+R (or Cmd+Shift+R on Mac)', - ); - } - - sync(); - }; - - checkUserAndSync(); - // eslint-disable-next-line react-hooks/exhaustive-deps + // If user changed, clear transactions + if (lastUserIdRef.current && lastUserIdRef.current !== currentUser.id) { + transactionSyncService.clearAll(); + } + lastUserIdRef.current = currentUser.id; }, [isAuthenticated, currentUser]); - // Auto-sync when pending changes are detected - useEffect(() => { - if (!isAuthenticated || !isOnline || pendingOperationsCount === 0) { - return; - } - - // Clear any existing timeout - if (autoSyncTimeoutRef.current) { - clearTimeout(autoSyncTimeoutRef.current); - } - - // Debounce sync to avoid too many calls - autoSyncTimeoutRef.current = setTimeout(() => { - sync(); - }, AUTO_SYNC_DEBOUNCE); - - return () => { - if (autoSyncTimeoutRef.current) { - clearTimeout(autoSyncTimeoutRef.current); - } - }; - }, [isAuthenticated, isOnline, pendingOperationsCount, sync]); - return ( {children} diff --git a/resources/js/hooks/use-re-evaluate-all-transactions.tsx b/resources/js/hooks/use-re-evaluate-all-transactions.tsx index f066555f..0cd4df40 100644 --- a/resources/js/hooks/use-re-evaluate-all-transactions.tsx +++ b/resources/js/hooks/use-re-evaluate-all-transactions.tsx @@ -2,9 +2,9 @@ import { decrypt, encrypt, importKey } from '@/lib/crypto'; import { getStoredKey } from '@/lib/key-storage'; import { evaluateRules } from '@/lib/rule-engine'; import { appendNoteIfNotPresent } from '@/lib/utils'; -import { automationRuleSyncService } from '@/services/automation-rule-sync'; import { transactionSyncService } from '@/services/transaction-sync'; import type { Account, Bank } from '@/types/account'; +import type { AutomationRule } from '@/types/automation-rule'; import type { Category } from '@/types/category'; import type { DecryptedTransaction } from '@/types/transaction'; import { useCallback } from 'react'; @@ -26,6 +26,7 @@ export function useReEvaluateAllTransactions() { categories: Category[], accounts: Account[], banks: Bank[], + automationRules: AutomationRule[], options?: ReEvaluateAllOptions, ) => { if (!transactions.length) { @@ -40,9 +41,8 @@ export function useReEvaluateAllTransactions() { } const key = await importKey(keyString); - const rules = await automationRuleSyncService.getAll(); - if (!rules.length) { + if (!automationRules.length) { toast.error('No automation rules found'); return; } @@ -67,7 +67,7 @@ export function useReEvaluateAllTransactions() { const result = await evaluateRules( transaction, - rules, + automationRules, categories, accounts, banks, diff --git a/resources/js/lib/db-migration-helper.ts b/resources/js/lib/db-migration-helper.ts deleted file mode 100644 index 01c06774..00000000 --- a/resources/js/lib/db-migration-helper.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { db } from './dexie-db'; - -export async function checkDatabaseVersion(): Promise<{ - needsRefresh: boolean; - missingStores: string[]; -}> { - const requiredStores = [ - 'categories', - 'accounts', - 'banks', - 'automation_rules', - 'transactions', - 'sync_metadata', - 'pending_changes', - ]; - - try { - await db.open(); - const existingStores = db.tables.map((table) => table.name); - const missingStores = requiredStores.filter( - (store) => !existingStores.includes(store), - ); - - if (missingStores.length > 0) { - console.warn( - 'Missing IndexedDB stores:', - missingStores, - '\nPlease refresh the page (Ctrl+Shift+R or Cmd+Shift+R)', - ); - } - - return { - needsRefresh: missingStores.length > 0, - missingStores, - }; - } catch (error) { - console.error('Failed to check database version:', error); - return { - needsRefresh: false, - missingStores: [], - }; - } -} diff --git a/resources/js/lib/dexie-db.ts b/resources/js/lib/dexie-db.ts index e17e68a6..ef7a38c8 100644 --- a/resources/js/lib/dexie-db.ts +++ b/resources/js/lib/dexie-db.ts @@ -1,8 +1,4 @@ -import type { Transaction } from '@/services/transaction-sync'; -import type { Account, AccountBalance, Bank } from '@/types/account'; -import type { AutomationRule } from '@/types/automation-rule'; -import type { Category } from '@/types/category'; -import type { Label } from '@/types/label'; +import type { Transaction } from '@/types/transaction'; import Dexie, { type EntityTable } from 'dexie'; export interface SyncMetadata { @@ -10,47 +6,67 @@ export interface SyncMetadata { value: string; } -export interface PendingChange { - id?: number; - store: string; - operation: 'create' | 'update' | 'delete'; - data: Record; - timestamp: string; -} - -const db = new Dexie('whisper_money') as Dexie & { +type WhisperMoneyDB = Dexie & { transactions: EntityTable; - accounts: EntityTable; - categories: EntityTable; - labels: EntityTable; - banks: EntityTable; - automation_rules: EntityTable; - account_balances: EntityTable; sync_metadata: EntityTable; - pending_changes: EntityTable; }; -db.version(5).stores({ - transactions: 'id, user_id, account_id, updated_at', - accounts: 'id, user_id, bank_id, updated_at', - categories: 'id, user_id, updated_at', - banks: 'id, user_id, updated_at', - automation_rules: 'id, user_id, priority, updated_at', - account_balances: 'id, account_id, balance_date, updated_at', - sync_metadata: 'key', - pending_changes: '++id, store, timestamp', -}); +let dbInstance: WhisperMoneyDB | null = null; -db.version(6).stores({ - transactions: 'id, user_id, account_id, updated_at', - accounts: 'id, user_id, bank_id, updated_at', - categories: 'id, user_id, updated_at', - labels: 'id, user_id, updated_at', - banks: 'id, user_id, updated_at', - automation_rules: 'id, user_id, priority, updated_at', - account_balances: 'id, account_id, balance_date, updated_at', - sync_metadata: 'key', - pending_changes: '++id, store, timestamp', -}); +function initializeDatabase(): WhisperMoneyDB { + const database = new Dexie('whisper_money') as WhisperMoneyDB; -export { db }; + database.version(5).stores({ + transactions: 'id, user_id, account_id, updated_at', + accounts: 'id, user_id, bank_id, updated_at', + categories: 'id, user_id, updated_at', + banks: 'id, user_id, updated_at', + automation_rules: 'id, user_id, priority, updated_at', + account_balances: 'id, account_id, balance_date, updated_at', + sync_metadata: 'key', + pending_changes: '++id, store, timestamp', + }); + + database.version(6).stores({ + transactions: 'id, user_id, account_id, updated_at', + accounts: 'id, user_id, bank_id, updated_at', + categories: 'id, user_id, updated_at', + labels: 'id, user_id, updated_at', + banks: 'id, user_id, updated_at', + automation_rules: 'id, user_id, priority, updated_at', + account_balances: 'id, account_id, balance_date, updated_at', + sync_metadata: 'key', + pending_changes: '++id, store, timestamp', + }); + + // Version 7: Remove all tables except transactions and sync_metadata + database.version(7).stores({ + transactions: 'id, user_id, account_id, updated_at', + sync_metadata: 'key', + // Delete removed tables + accounts: null, + categories: null, + labels: null, + banks: null, + automation_rules: null, + account_balances: null, + pending_changes: null, + }); + + // Version 8: Ensure clean state (no schema changes, just trigger upgrade) + database.version(8).stores({ + transactions: 'id, user_id, account_id, updated_at', + sync_metadata: 'key', + }); + + return database; +} + +export const db = new Proxy({} as WhisperMoneyDB, { + get(_target, prop) { + if (!dbInstance) { + dbInstance = initializeDatabase(); + } + return dbInstance[prop as keyof WhisperMoneyDB]; + }, +}); diff --git a/resources/js/lib/sync-manager.ts b/resources/js/lib/sync-manager.ts index 609e8676..b69297c8 100644 --- a/resources/js/lib/sync-manager.ts +++ b/resources/js/lib/sync-manager.ts @@ -1,58 +1,40 @@ +import type { Transaction } from '@/types/transaction'; import type { UUID } from '@/types/uuid'; -import axios, { AxiosError } from 'axios'; -import { uuidv7 } from 'uuidv7'; +import axios from 'axios'; import { db } from './dexie-db'; -export type StoreName = - | 'categories' - | 'accounts' - | 'banks' - | 'automation_rules' - | 'transactions'; - -export interface IndexedDBRecord { - id: UUID; - user_id?: UUID | null; - created_at: string; - updated_at: string; - [key: string]: unknown; -} - -export interface SyncOptions { - storeName: StoreName; - endpoint: string; - transformFromServer?: ( - data: Record, - ) => Record; - transformToServer?: ( - data: Record, - ) => Record; -} - export interface SyncResult { success: boolean; inserted: number; updated: number; - deleted: number; errors: string[]; } -export class SyncManager { - private syncInProgress = false; - private lastSyncKey: string; +interface SyncOptions { + endpoint: string; + transformFromServer?: ( + data: Record, + ) => Record; +} - constructor(private options: SyncOptions) { - this.lastSyncKey = `last_sync_${options.storeName}`; +const LAST_SYNC_KEY = 'last_sync_transactions'; + +export class TransactionSyncManager { + private syncInProgress = false; + private options: SyncOptions; + + constructor(options: SyncOptions) { + this.options = options; } async getLastSyncTime(): Promise { - const metadata = await db.sync_metadata.get(this.lastSyncKey); + const metadata = await db.sync_metadata.get(LAST_SYNC_KEY); return metadata?.value || null; } async setLastSyncTime(timestamp: string): Promise { await db.sync_metadata.put({ - key: this.lastSyncKey, + key: LAST_SYNC_KEY, value: timestamp, }); } @@ -63,7 +45,6 @@ export class SyncManager { success: false, inserted: 0, updated: 0, - deleted: 0, errors: ['Sync already in progress'], }; } @@ -74,18 +55,11 @@ export class SyncManager { success: true, inserted: 0, updated: 0, - deleted: 0, errors: [], }; try { await this.syncFromServer(result); - const successfulChangeIds = await this.syncToServer(result); - - if (successfulChangeIds.length > 0) { - await db.pending_changes.bulkDelete(successfulChangeIds); - } - await this.setLastSyncTime(new Date().toISOString()); } catch (error) { result.success = false; @@ -115,22 +89,18 @@ export class SyncManager { throw new Error('Invalid server response format'); } - const table = db[this.options.storeName]; - const localRecords = await table.toArray(); - const localMap = new Map( - localRecords.map((r) => [ - (r as IndexedDBRecord).id, - r as IndexedDBRecord, - ]), - ); + const localRecords = await db.transactions.toArray(); + const localMap = new Map(localRecords.map((r) => [r.id, r])); - const toInsert: Record[] = []; - const toUpdate: Record[] = []; + const toInsert: Transaction[] = []; + const toUpdate: Transaction[] = []; for (const serverRecord of serverData) { - const transformed = this.options.transformFromServer - ? this.options.transformFromServer(serverRecord) - : serverRecord; + const transformed = ( + this.options.transformFromServer + ? this.options.transformFromServer(serverRecord) + : serverRecord + ) as Transaction; const localRecord = localMap.get(transformed.id); @@ -147,163 +117,37 @@ export class SyncManager { } if (toInsert.length > 0) { - await table.bulkPut(toInsert); + await db.transactions.bulkPut(toInsert); result.inserted += toInsert.length; } if (toUpdate.length > 0) { - await table.bulkPut(toUpdate); + await db.transactions.bulkPut(toUpdate); result.updated += toUpdate.length; } } - private async syncToServer(result: SyncResult): Promise { - const pendingChanges = await db.pending_changes - .where('store') - .equals(this.options.storeName) + async getAll(): Promise { + return await db.transactions.toArray(); + } + + async getById(id: UUID): Promise { + return (await db.transactions.get(id)) || null; + } + + async getByAccountId(accountId: UUID): Promise { + return await db.transactions + .where('account_id') + .equals(accountId) .toArray(); - - const successfulChangeIds: number[] = []; - - for (const change of pendingChanges) { - try { - const data = this.options.transformToServer - ? this.options.transformToServer(change.data) - : change.data; - - switch (change.operation) { - case 'create': - await axios.post(this.options.endpoint, data); - result.inserted++; - break; - case 'update': - try { - await axios.patch( - `${this.options.endpoint}/${change.data.id}`, - data, - ); - result.updated++; - } catch (updateError) { - if ( - updateError instanceof AxiosError && - updateError.response?.status === 404 - ) { - await axios.post(this.options.endpoint, data); - result.inserted++; - } else { - throw updateError; - } - } - break; - case 'delete': - try { - await axios.delete( - `${this.options.endpoint}/${change.data.id}`, - ); - result.deleted++; - } catch (deleteError) { - if ( - deleteError instanceof AxiosError && - deleteError.response?.status === 404 - ) { - result.deleted++; - } else { - throw deleteError; - } - } - break; - } - - if (change.id !== undefined) { - successfulChangeIds.push(change.id); - } - } catch (error) { - result.errors.push( - `Failed to sync ${change.operation} for ${this.options.storeName}: ${error}`, - ); - } - } - - return successfulChangeIds; - } - - async createLocal( - data: Omit, - ): Promise { - const timestamp = new Date().toISOString(); - const id = uuidv7(); - - const record = { - ...data, - id, - created_at: timestamp, - updated_at: timestamp, - } as T; - - const table = db[this.options.storeName]; - await table.put(record); - await db.pending_changes.add({ - store: this.options.storeName, - operation: 'create', - data: record, - timestamp, - }); - - return record; - } - - async updateLocal( - id: UUID, - data: Partial, - ): Promise { - const table = db[this.options.storeName]; - const existing = await table.get(id); - - if (!existing) { - throw new Error( - `Record ${id} not found in ${this.options.storeName}`, - ); - } - - const timestamp = new Date().toISOString(); - const updated = { - ...existing, - ...data, - updated_at: timestamp, - }; - - await table.put(updated); - await db.pending_changes.add({ - store: this.options.storeName, - operation: 'update', - data: updated, - timestamp, - }); - } - - async deleteLocal(id: UUID): Promise { - const timestamp = new Date().toISOString(); - const table = db[this.options.storeName]; - await table.delete(id); - await db.pending_changes.add({ - store: this.options.storeName, - operation: 'delete', - data: { id }, - timestamp, - }); - } - - async getAll(): Promise { - const table = db[this.options.storeName]; - return (await table.toArray()) as T[]; - } - - async getById(id: UUID): Promise { - const table = db[this.options.storeName]; - return ((await table.get(id)) as T) || null; } isSyncing(): boolean { return this.syncInProgress; } + + async clearAll(): Promise { + await db.transactions.clear(); + await db.sync_metadata.delete(LAST_SYNC_KEY); + } } diff --git a/resources/js/lib/user-session-storage.ts b/resources/js/lib/user-session-storage.ts deleted file mode 100644 index df946cb6..00000000 --- a/resources/js/lib/user-session-storage.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { UUID } from '@/types/uuid'; -import { db } from './dexie-db'; - -const USER_ID_KEY = 'current_user_id'; - -function isBrowser(): boolean { - return typeof window !== 'undefined'; -} - -export async function getStoredUserId(): Promise { - if (!isBrowser()) return null; - - try { - await db.open(); - const metadata = await db.sync_metadata.get(USER_ID_KEY); - return (metadata?.value as UUID) || null; - } catch { - return null; - } -} - -export async function setStoredUserId(userId: UUID): Promise { - if (!isBrowser()) return; - - try { - await db.open(); - await db.sync_metadata.put({ key: USER_ID_KEY, value: userId }); - } catch (error) { - console.error('Failed to store user ID:', error); - } -} - -export async function clearAllUserData(): Promise { - if (!isBrowser()) return; - - // Clear all tables including sync_metadata (which contains last_sync timestamps) - // This ensures a fresh start for the new user without stale sync timestamps - try { - await db.open(); - await Promise.all([ - db.transactions.clear(), - db.accounts.clear(), - db.categories.clear(), - db.banks.clear(), - db.automation_rules.clear(), - db.account_balances.clear(), - db.sync_metadata.clear(), - db.pending_changes.clear(), - ]); - } catch { - // If clearing fails, delete and recreate the database - await db.delete(); - await db.open(); - } -} - -export async function handleUserChange(newUserId: UUID): Promise { - const storedUserId = await getStoredUserId(); - - // Different user logged in - clear all data and set new user - if (storedUserId && storedUserId !== newUserId) { - await clearAllUserData(); - await setStoredUserId(newUserId); - return true; - } - - // No stored user ID (first time or after signup/clear) - store the new user ID - if (!storedUserId) { - await setStoredUserId(newUserId); - } - - // Same user or just stored - no clearing needed - return false; -} diff --git a/resources/js/pages/Accounts/Show.tsx b/resources/js/pages/Accounts/Show.tsx index 4d20753d..19b21723 100644 --- a/resources/js/pages/Accounts/Show.tsx +++ b/resources/js/pages/Accounts/Show.tsx @@ -205,6 +205,7 @@ export default function AccountShow({ diff --git a/resources/js/pages/auth/register.tsx b/resources/js/pages/auth/register.tsx index d33be502..3d5dc4a2 100644 --- a/resources/js/pages/auth/register.tsx +++ b/resources/js/pages/auth/register.tsx @@ -10,7 +10,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; import AuthLayout from '@/layouts/auth-layout'; -import { clearAllUserData } from '@/lib/user-session-storage'; +import { transactionSyncService } from '@/services/transaction-sync'; interface RegisterProps { hideAuthButtons?: boolean; @@ -24,7 +24,7 @@ export default function Register({ hideAuthButtons = false }: RegisterProps) { }, [hideAuthButtons]); const handleBeforeSubmit = useCallback(async () => { - await clearAllUserData(); + await transactionSyncService.clearAll(); return true; }, []); diff --git a/resources/js/pages/settings/accounts.tsx b/resources/js/pages/settings/accounts.tsx index 6d263d2b..1fd57a0e 100644 --- a/resources/js/pages/settings/accounts.tsx +++ b/resources/js/pages/settings/accounts.tsx @@ -1,4 +1,4 @@ -import { Head } from '@inertiajs/react'; +import { Head, router } from '@inertiajs/react'; import { Cell, ColumnDef, @@ -13,7 +13,6 @@ import { useReactTable, VisibilityState, } from '@tanstack/react-table'; -import { useLiveQuery } from 'dexie-react-hooks'; import { ArrowUpDown, MoreHorizontal } from 'lucide-react'; import { useState } from 'react'; @@ -50,8 +49,6 @@ import { } from '@/components/ui/table'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; -import { db } from '@/lib/dexie-db'; -import { accountSyncService } from '@/services/account-sync'; import { type BreadcrumbItem } from '@/types'; import { type Account, formatAccountType } from '@/types/account'; @@ -76,7 +73,11 @@ function AccountActions({ <> - @@ -175,16 +176,19 @@ function AccountRow({ ); } -export default function Accounts() { - const accounts = useLiveQuery(() => db.accounts.toArray(), []) || []; +interface AccountsPageProps { + accounts: Account[]; +} + +export default function Accounts({ accounts }: AccountsPageProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState( {}, ); - const handleAccountCreated = async () => { - await accountSyncService.sync(); + const handleAccountCreated = () => { + router.reload({ only: ['accounts'] }); }; const columns: ColumnDef[] = [ diff --git a/resources/js/pages/settings/automation-rules.tsx b/resources/js/pages/settings/automation-rules.tsx index 389b6d00..088b11d3 100644 --- a/resources/js/pages/settings/automation-rules.tsx +++ b/resources/js/pages/settings/automation-rules.tsx @@ -1,4 +1,4 @@ -import { Head } from '@inertiajs/react'; +import { Head, usePage } from '@inertiajs/react'; import { Cell, ColumnDef, @@ -12,7 +12,6 @@ import { useReactTable, VisibilityState, } from '@tanstack/react-table'; -import { useLiveQuery } from 'dexie-react-hooks'; import * as Icons from 'lucide-react'; import { MoreHorizontal } from 'lucide-react'; import { useMemo, useState } from 'react'; @@ -51,10 +50,10 @@ import { import { useEncryptionKey } from '@/contexts/encryption-key-context'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; -import { db } from '@/lib/dexie-db'; import { type BreadcrumbItem } from '@/types'; import { type AutomationRule, getRuleActions } from '@/types/automation-rule'; -import { getCategoryColorClasses } from '@/types/category'; +import { type Category, getCategoryColorClasses } from '@/types/category'; +import { type Label } from '@/types/label'; const breadcrumbs: BreadcrumbItem[] = [ { @@ -63,7 +62,15 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; -function AutomationRuleActions({ rule }: { rule: AutomationRule }) { +function AutomationRuleActions({ + rule, + categories, + labels, +}: { + rule: AutomationRule; + categories: Category[]; + labels: Label[]; +}) { const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -71,7 +78,11 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) { <> - @@ -92,6 +103,8 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) { @@ -104,7 +117,15 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) { ); } -function AutomationRuleRow({ row }: { row: Row }) { +function AutomationRuleRow({ + row, + categories, + labels, +}: { + row: Row; + categories: Category[]; + labels: Label[]; +}) { const rule = row.original; const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -148,6 +169,8 @@ function AutomationRuleRow({ row }: { row: Row }) { @@ -162,8 +185,13 @@ function AutomationRuleRow({ row }: { row: Row }) { export default function AutomationRules() { const { isKeySet } = useEncryptionKey(); - const rawRules = - useLiveQuery(() => db.automation_rules.toArray(), []) || []; + const { automationRules: rawRules } = usePage<{ + automationRules: AutomationRule[]; + }>().props; + + // Get categories and labels from globally shared Inertia data + const categories = usePage().props.categories as Category[]; + const labels = usePage().props.labels as Label[]; const rules = useMemo( () => rawRules.map((rule) => ({ @@ -276,7 +304,13 @@ export default function AutomationRules() { { id: 'actions', enableHiding: false, - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, ]; @@ -323,7 +357,11 @@ export default function AutomationRules() { } className="max-w-sm" /> - +
@@ -363,6 +401,8 @@ export default function AutomationRules() { )) ) : ( diff --git a/resources/js/pages/settings/categories.tsx b/resources/js/pages/settings/categories.tsx index 7b30369c..6617a1cf 100644 --- a/resources/js/pages/settings/categories.tsx +++ b/resources/js/pages/settings/categories.tsx @@ -1,4 +1,4 @@ -import { Head } from '@inertiajs/react'; +import { Head, usePage } from '@inertiajs/react'; import { Cell, ColumnDef, @@ -12,7 +12,6 @@ import { useReactTable, VisibilityState, } from '@tanstack/react-table'; -import { useLiveQuery } from 'dexie-react-hooks'; import * as Icons from 'lucide-react'; import { ArrowUpDown, MoreHorizontal } from 'lucide-react'; import { useState } from 'react'; @@ -49,7 +48,6 @@ import { } from '@/components/ui/table'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; -import { db } from '@/lib/dexie-db'; import { type BreadcrumbItem } from '@/types'; import { type Category, getCategoryColorClasses } from '@/types/category'; @@ -162,7 +160,7 @@ function CategoryRow({ row }: { row: Row }) { } export default function Categories() { - const categories = useLiveQuery(() => db.categories.toArray(), []) || []; + const { categories } = usePage<{ categories: Category[] }>().props; const [sorting, setSorting] = useState([ { id: 'name', desc: false }, ]); diff --git a/resources/js/pages/settings/labels.tsx b/resources/js/pages/settings/labels.tsx index c9d1e9a3..78e16a97 100644 --- a/resources/js/pages/settings/labels.tsx +++ b/resources/js/pages/settings/labels.tsx @@ -1,4 +1,4 @@ -import { Head } from '@inertiajs/react'; +import { Head, usePage } from '@inertiajs/react'; import { Cell, ColumnDef, @@ -12,7 +12,6 @@ import { useReactTable, VisibilityState, } from '@tanstack/react-table'; -import { useLiveQuery } from 'dexie-react-hooks'; import { ArrowUpDown, MoreHorizontal, Tag } from 'lucide-react'; import { useState } from 'react'; @@ -48,7 +47,6 @@ import { } from '@/components/ui/table'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; -import { db } from '@/lib/dexie-db'; import { type BreadcrumbItem } from '@/types'; import { getLabelColorClasses, type Label } from '@/types/label'; @@ -161,7 +159,7 @@ function LabelRow({ row }: { row: Row