Automation rules
This commit is contained in:
parent
ea9cec184f
commit
d5ce7a24b4
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\StoreAutomationRuleRequest;
|
||||
use App\Http\Requests\Settings\UpdateAutomationRuleRequest;
|
||||
use App\Models\AutomationRule;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class AutomationRuleController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
/**
|
||||
* Show the user's automation rules settings page.
|
||||
*/
|
||||
public function index(): Response
|
||||
{
|
||||
$rules = auth()->user()
|
||||
->automationRules()
|
||||
->with('category:id,name,icon,color')
|
||||
->orderBy('priority')
|
||||
->get(['id', 'title', 'priority', 'rules_json', 'action_category_id', 'action_note', 'action_note_iv']);
|
||||
|
||||
return Inertia::render('settings/automation-rules', [
|
||||
'rules' => $rules,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created automation rule.
|
||||
*/
|
||||
public function store(StoreAutomationRuleRequest $request): RedirectResponse
|
||||
{
|
||||
auth()->user()->automationRules()->create($request->validated());
|
||||
|
||||
return to_route('automation-rules.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified automation rule.
|
||||
*/
|
||||
public function update(UpdateAutomationRuleRequest $request, AutomationRule $automationRule): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $automationRule);
|
||||
|
||||
$automationRule->update($request->validated());
|
||||
|
||||
return to_route('automation-rules.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete the specified automation rule.
|
||||
*/
|
||||
public function destroy(AutomationRule $automationRule): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $automationRule);
|
||||
|
||||
$automationRule->delete();
|
||||
|
||||
return to_route('automation-rules.index');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sync;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class AutomationRuleSyncController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get all automation rules for the authenticated user.
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$rules = auth()->user()
|
||||
->automationRules()
|
||||
->with('category:id,name,icon,color')
|
||||
->orderBy('priority')
|
||||
->get();
|
||||
|
||||
return response()->json($rules);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreAutomationRuleRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'priority' => ['required', 'integer', 'min:0'],
|
||||
'rules_json' => ['required', 'json', function ($attribute, $value, $fail) {
|
||||
$decoded = json_decode($value, true);
|
||||
if (! is_array($decoded) || empty($decoded)) {
|
||||
$fail('The rules JSON must be a valid JsonLogic object.');
|
||||
}
|
||||
}],
|
||||
'action_category_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('categories', 'id')->where(function ($query) {
|
||||
$query->where('user_id', auth()->id());
|
||||
}),
|
||||
],
|
||||
'action_note' => ['nullable', 'string'],
|
||||
'action_note_iv' => ['nullable', 'string', 'required_with:action_note'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the validator instance.
|
||||
*/
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
if (! $this->action_category_id && ! $this->action_note) {
|
||||
$validator->errors()->add('action_category_id', 'At least one action (category or note) must be provided.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateAutomationRuleRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'priority' => ['required', 'integer', 'min:0'],
|
||||
'rules_json' => ['required', 'json', function ($attribute, $value, $fail) {
|
||||
$decoded = json_decode($value, true);
|
||||
if (! is_array($decoded) || empty($decoded)) {
|
||||
$fail('The rules JSON must be a valid JsonLogic object.');
|
||||
}
|
||||
}],
|
||||
'action_category_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('categories', 'id')->where(function ($query) {
|
||||
$query->where('user_id', auth()->id());
|
||||
}),
|
||||
],
|
||||
'action_note' => ['nullable', 'string'],
|
||||
'action_note_iv' => ['nullable', 'string', 'required_with:action_note'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the validator instance.
|
||||
*/
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
if (! $this->action_category_id && ! $this->action_note) {
|
||||
$validator->errors()->add('action_category_id', 'At least one action (category or note) must be provided.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class AutomationRule extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\AutomationRuleFactory> */
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'title',
|
||||
'priority',
|
||||
'rules_json',
|
||||
'action_category_id',
|
||||
'action_note',
|
||||
'action_note_iv',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'rules_json' => 'array',
|
||||
'priority' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'action_category_id');
|
||||
}
|
||||
}
|
||||
|
|
@ -72,4 +72,9 @@ class User extends Authenticatable
|
|||
{
|
||||
return $this->hasMany(Category::class);
|
||||
}
|
||||
|
||||
public function automationRules(): HasMany
|
||||
{
|
||||
return $this->hasMany(AutomationRule::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\User;
|
||||
|
||||
class AutomationRulePolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, AutomationRule $automationRule): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, AutomationRule $automationRule): bool
|
||||
{
|
||||
return $user->id === $automationRule->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, AutomationRule $automationRule): bool
|
||||
{
|
||||
return $user->id === $automationRule->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, AutomationRule $automationRule): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, AutomationRule $automationRule): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AutomationRule>
|
||||
*/
|
||||
class AutomationRuleFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'title' => fake()->sentence(3),
|
||||
'priority' => fake()->numberBetween(0, 100),
|
||||
'rules_json' => [
|
||||
'and' => [
|
||||
['>' => [['var' => 'amount'], 100]],
|
||||
['in' => ['grocery', ['var' => 'description']]],
|
||||
],
|
||||
],
|
||||
'action_category_id' => null,
|
||||
'action_note' => null,
|
||||
'action_note_iv' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('automation_rules', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('title');
|
||||
$table->integer('priority')->default(0);
|
||||
$table->json('rules_json');
|
||||
$table->foreignId('action_category_id')->nullable()->constrained('categories')->nullOnDelete();
|
||||
$table->text('action_note')->nullable();
|
||||
$table->string('action_note_iv')->nullable();
|
||||
$table->softDeletes();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('automation_rules');
|
||||
}
|
||||
};
|
||||
|
|
@ -11,13 +11,15 @@
|
|||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
|
|
@ -28,22 +30,29 @@
|
|||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"globals": "^15.14.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"json-logic-js": "^2.0.5",
|
||||
"laravel-vite-plugin": "^2.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
"lucide-react": "^0.553.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.0.4"
|
||||
"uuidv7": "^1.0.2",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^7.0.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@laravel/vite-plugin-wayfinder": "^0.1.3",
|
||||
"@types/json-logic-js": "^2.0.8",
|
||||
"@types/node": "^22.13.5",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.17.0",
|
||||
|
|
@ -1128,6 +1137,24 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
|
|
@ -1259,6 +1286,24 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
|
|
@ -1322,6 +1367,24 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||
|
|
@ -1506,6 +1569,24 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-navigation-menu": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
|
||||
|
|
@ -1541,6 +1622,61 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
||||
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
|
|
@ -1640,6 +1776,56 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
|
||||
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||
|
|
@ -1712,12 +1898,54 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
|
||||
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
|
||||
"integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
||||
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
|
|
@ -1735,9 +1963,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
|
|
@ -1836,6 +2065,24 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
|
|
@ -2753,6 +3000,13 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
|
||||
},
|
||||
"node_modules/@types/json-logic-js": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-logic-js/-/json-logic-js-2.0.8.tgz",
|
||||
"integrity": "sha512-WgNsDPuTPKYXl0Jh0IfoCoJoAGGYZt5qzpmjuLSEg7r0cKp/kWtWp0HAsVepyPSPyXiHo6uXp/B/kW/2J1fa2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
|
|
@ -3097,6 +3351,15 @@
|
|||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
|
|
@ -3464,6 +3727,19 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
|
|
@ -3530,6 +3806,31 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
|
@ -3592,6 +3893,18 @@
|
|||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -3662,6 +3975,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
|
|
@ -4388,6 +4711,15 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -5177,6 +5509,12 @@
|
|||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/json-logic-js": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/json-logic-js/-/json-logic-js-2.0.5.tgz",
|
||||
"integrity": "sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
|
|
@ -5524,9 +5862,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.475.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz",
|
||||
"integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==",
|
||||
"version": "0.553.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.553.0.tgz",
|
||||
"integrity": "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
|
|
@ -6603,6 +6942,18 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
|
|
@ -7142,6 +7493,28 @@
|
|||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuidv7": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuidv7/-/uuidv7-1.0.2.tgz",
|
||||
"integrity": "sha512-8JQkH4ooXnm1JCIhqTMbtmdnYEn6oKukBxHn1Ic9878jMkL7daTI7anTExfY18VRCX7tcdn5quzvCb6EWrR8PA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"uuidv7": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz",
|
||||
|
|
@ -7351,6 +7724,24 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
|
@ -7376,6 +7767,27 @@
|
|||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@laravel/vite-plugin-wayfinder": "^0.1.3",
|
||||
"@types/json-logic-js": "^2.0.8",
|
||||
"@types/node": "^22.13.5",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.17.0",
|
||||
|
|
@ -56,6 +57,7 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"globals": "^15.14.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"json-logic-js": "^2.0.5",
|
||||
"laravel-vite-plugin": "^2.0",
|
||||
"lucide-react": "^0.553.0",
|
||||
"react": "^19.2.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,316 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { router } from '@inertiajs/react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { store } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import { encrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import type { Category } from '@/types/category';
|
||||
|
||||
interface CreateAutomationRuleDialogProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function CreateAutomationRuleDialog({
|
||||
disabled = false,
|
||||
}: CreateAutomationRuleDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [priority, setPriority] = useState('0');
|
||||
const [rulesJson, setRulesJson] = useState('');
|
||||
const [categoryId, setCategoryId] = useState<string>('');
|
||||
const [note, setNote] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
const data = await categorySyncService.getAll();
|
||||
setCategories(data);
|
||||
};
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
const validateJsonLogic = (json: string): boolean => {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return false;
|
||||
}
|
||||
return Object.keys(parsed).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
|
||||
if (!title.trim()) {
|
||||
setErrors((prev) => ({ ...prev, title: 'Title is required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rulesJson.trim()) {
|
||||
setErrors((prev) => ({ ...prev, rules_json: 'Rules JSON is required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateJsonLogic(rulesJson)) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
rules_json: 'Invalid JsonLogic format',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!categoryId && !note.trim()) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
action_category_id: 'At least one action is required',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
let encryptedNote: string | null = null;
|
||||
let noteIv: string | null = null;
|
||||
|
||||
if (note.trim()) {
|
||||
const keyString = getStoredKey();
|
||||
if (!keyString) {
|
||||
throw new Error('Encryption key not available');
|
||||
}
|
||||
const key = await importKey(keyString);
|
||||
const encrypted = await encrypt(note.trim(), key);
|
||||
encryptedNote = encrypted.encrypted;
|
||||
noteIv = encrypted.iv;
|
||||
}
|
||||
|
||||
router.post(
|
||||
store().url,
|
||||
{
|
||||
title: title.trim(),
|
||||
priority: parseInt(priority, 10),
|
||||
rules_json: rulesJson.trim(),
|
||||
action_category_id: categoryId ? parseInt(categoryId, 10) : null,
|
||||
action_note: encryptedNote,
|
||||
action_note_iv: noteIv,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setTitle('');
|
||||
setPriority('0');
|
||||
setRulesJson('');
|
||||
setCategoryId('');
|
||||
setNote('');
|
||||
setErrors({});
|
||||
},
|
||||
onError: (errors) => {
|
||||
setErrors(errors as Record<string, string>);
|
||||
},
|
||||
onFinish: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to create automation rule:', error);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={disabled}>Create Rule</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Automation Rule</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a rule to automatically categorize transactions or add notes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Rule title"
|
||||
required
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-sm text-red-500">{errors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="priority">Priority</Label>
|
||||
<Input
|
||||
id="priority"
|
||||
type="number"
|
||||
min="0"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
placeholder="0"
|
||||
required
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Lower numbers execute first
|
||||
</p>
|
||||
{errors.priority && (
|
||||
<p className="text-sm text-red-500">{errors.priority}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rules_json">Rules (JsonLogic)</Label>
|
||||
<Textarea
|
||||
id="rules_json"
|
||||
value={rulesJson}
|
||||
onChange={(e) => setRulesJson(e.target.value)}
|
||||
placeholder='{"==": [{"var": "description"}, "GROCERY"]}'
|
||||
rows={4}
|
||||
className="font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
{errors.rules_json && (
|
||||
<p className="text-sm text-red-500">{errors.rules_json}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Collapsible open={helpOpen} onOpenChange={setHelpOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<span>Available Fields & Examples</span>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${helpOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 rounded-md border p-3 text-sm">
|
||||
<div>
|
||||
<strong>Available fields:</strong>
|
||||
<ul className="ml-4 mt-1 list-disc">
|
||||
<li>description (string)</li>
|
||||
<li>amount (number)</li>
|
||||
<li>transaction_date (string)</li>
|
||||
<li>bank_name (string)</li>
|
||||
<li>account_name (string)</li>
|
||||
<li>category (string or null)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Example rules:</strong>
|
||||
<pre className="bg-muted mt-1 overflow-x-auto rounded p-2 text-xs">
|
||||
{`{"in": ["GROCERY", {"var": "description"}]}
|
||||
{"and": [
|
||||
{">": [{"var": "amount"}, 100]},
|
||||
{"==": [{"var": "bank_name"}, "Chase"]}
|
||||
]}
|
||||
{"==": [{"var": "category"}, null]}`}
|
||||
</pre>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<h4 className="font-medium">Actions</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
At least one action is required
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Set Category</Label>
|
||||
<Select value={categoryId} onValueChange={setCategoryId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem
|
||||
key={category.id}
|
||||
value={String(category.id)}
|
||||
>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="note">Add Note</Label>
|
||||
<Textarea
|
||||
id="note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Note to add (optional)"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errors.action_category_id && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.action_category_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { useState } from 'react';
|
||||
import { router } from '@inertiajs/react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { destroy } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
|
||||
import type { AutomationRule } from '@/types/automation-rule';
|
||||
|
||||
interface DeleteAutomationRuleDialogProps {
|
||||
rule: AutomationRule;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function DeleteAutomationRuleDialog({
|
||||
rule,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DeleteAutomationRuleDialogProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
setIsDeleting(true);
|
||||
router.delete(destroy(rule.id).url, {
|
||||
onSuccess: () => {
|
||||
onOpenChange(false);
|
||||
},
|
||||
onFinish: () => {
|
||||
setIsDeleting(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Automation Rule</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{rule.title}"? This action cannot
|
||||
be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { router } from '@inertiajs/react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { update } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import { encrypt, decrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import type { Category } from '@/types/category';
|
||||
import type { AutomationRule } from '@/types/automation-rule';
|
||||
|
||||
interface EditAutomationRuleDialogProps {
|
||||
rule: AutomationRule;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditAutomationRuleDialog({
|
||||
rule,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: EditAutomationRuleDialogProps) {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [priority, setPriority] = useState('0');
|
||||
const [rulesJson, setRulesJson] = useState('');
|
||||
const [categoryId, setCategoryId] = useState<string>('');
|
||||
const [note, setNote] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
const data = await categorySyncService.getAll();
|
||||
setCategories(data);
|
||||
};
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (rule && open) {
|
||||
setTitle(rule.title);
|
||||
setPriority(String(rule.priority));
|
||||
setRulesJson(JSON.stringify(rule.rules_json, null, 2));
|
||||
setCategoryId(rule.action_category_id ? String(rule.action_category_id) : '');
|
||||
|
||||
const decryptNote = async () => {
|
||||
if (rule.action_note && rule.action_note_iv) {
|
||||
try {
|
||||
const keyString = getStoredKey();
|
||||
if (keyString) {
|
||||
const key = await importKey(keyString);
|
||||
const decrypted = await decrypt(
|
||||
rule.action_note,
|
||||
key,
|
||||
rule.action_note_iv,
|
||||
);
|
||||
setNote(decrypted);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt note:', error);
|
||||
}
|
||||
} else {
|
||||
setNote('');
|
||||
}
|
||||
};
|
||||
|
||||
decryptNote();
|
||||
}
|
||||
}, [rule, open]);
|
||||
|
||||
const validateJsonLogic = (json: string): boolean => {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return false;
|
||||
}
|
||||
return Object.keys(parsed).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
|
||||
if (!title.trim()) {
|
||||
setErrors((prev) => ({ ...prev, title: 'Title is required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rulesJson.trim()) {
|
||||
setErrors((prev) => ({ ...prev, rules_json: 'Rules JSON is required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateJsonLogic(rulesJson)) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
rules_json: 'Invalid JsonLogic format',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!categoryId && !note.trim()) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
action_category_id: 'At least one action is required',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
let encryptedNote: string | null = null;
|
||||
let noteIv: string | null = null;
|
||||
|
||||
if (note.trim()) {
|
||||
const keyString = getStoredKey();
|
||||
if (!keyString) {
|
||||
throw new Error('Encryption key not available');
|
||||
}
|
||||
const key = await importKey(keyString);
|
||||
const encrypted = await encrypt(note.trim(), key);
|
||||
encryptedNote = encrypted.encrypted;
|
||||
noteIv = encrypted.iv;
|
||||
}
|
||||
|
||||
router.patch(
|
||||
update(rule.id).url,
|
||||
{
|
||||
title: title.trim(),
|
||||
priority: parseInt(priority, 10),
|
||||
rules_json: rulesJson.trim(),
|
||||
action_category_id: categoryId ? parseInt(categoryId, 10) : null,
|
||||
action_note: encryptedNote,
|
||||
action_note_iv: noteIv,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false);
|
||||
setErrors({});
|
||||
},
|
||||
onError: (errors) => {
|
||||
setErrors(errors as Record<string, string>);
|
||||
},
|
||||
onFinish: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to update automation rule:', error);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Automation Rule</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the rule to automatically categorize transactions or add notes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Rule title"
|
||||
required
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-sm text-red-500">{errors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="priority">Priority</Label>
|
||||
<Input
|
||||
id="priority"
|
||||
type="number"
|
||||
min="0"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
placeholder="0"
|
||||
required
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Lower numbers execute first
|
||||
</p>
|
||||
{errors.priority && (
|
||||
<p className="text-sm text-red-500">{errors.priority}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rules_json">Rules (JsonLogic)</Label>
|
||||
<Textarea
|
||||
id="rules_json"
|
||||
value={rulesJson}
|
||||
onChange={(e) => setRulesJson(e.target.value)}
|
||||
placeholder='{"==": [{"var": "description"}, "GROCERY"]}'
|
||||
rows={4}
|
||||
className="font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
{errors.rules_json && (
|
||||
<p className="text-sm text-red-500">{errors.rules_json}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Collapsible open={helpOpen} onOpenChange={setHelpOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<span>Available Fields & Examples</span>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${helpOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 rounded-md border p-3 text-sm">
|
||||
<div>
|
||||
<strong>Available fields:</strong>
|
||||
<ul className="ml-4 mt-1 list-disc">
|
||||
<li>description (string)</li>
|
||||
<li>amount (number)</li>
|
||||
<li>transaction_date (string)</li>
|
||||
<li>bank_name (string)</li>
|
||||
<li>account_name (string)</li>
|
||||
<li>category (string or null)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Example rules:</strong>
|
||||
<pre className="bg-muted mt-1 overflow-x-auto rounded p-2 text-xs">
|
||||
{`{"in": ["GROCERY", {"var": "description"}]}
|
||||
{"and": [
|
||||
{">": [{"var": "amount"}, 100]},
|
||||
{"==": [{"var": "bank_name"}, "Chase"]}
|
||||
]}
|
||||
{"==": [{"var": "category"}, null]}`}
|
||||
</pre>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<h4 className="font-medium">Actions</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
At least one action is required
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Set Category</Label>
|
||||
<Select value={categoryId} onValueChange={setCategoryId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem
|
||||
key={category.id}
|
||||
value={String(category.id)}
|
||||
>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="note">Add Note</Label>
|
||||
<Textarea
|
||||
id="note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Note to add (optional)"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errors.action_category_id && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.action_category_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,18 +1,15 @@
|
|||
import { X, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
} from '@/components/ui/button-group';
|
||||
import { BulkCategorySelect } from '@/components/transactions/bulk-category-select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonGroup } from '@/components/ui/button-group';
|
||||
import { type Category } from '@/types/category';
|
||||
import { RefreshCw, Trash2, X } from 'lucide-react';
|
||||
|
||||
interface BulkActionsBarProps {
|
||||
selectedCount: number;
|
||||
categories: Category[];
|
||||
onCategoryChange: (categoryId: number | null) => void;
|
||||
onDelete: () => void;
|
||||
onReEvaluateRules: () => void;
|
||||
onClear: () => void;
|
||||
isUpdating?: boolean;
|
||||
}
|
||||
|
|
@ -22,6 +19,7 @@ export function BulkActionsBar({
|
|||
categories,
|
||||
onCategoryChange,
|
||||
onDelete,
|
||||
onReEvaluateRules,
|
||||
onClear,
|
||||
isUpdating = false,
|
||||
}: BulkActionsBarProps) {
|
||||
|
|
@ -30,9 +28,9 @@ export function BulkActionsBar({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||
<div className="flex flex-row justify-between items-center gap-10 bg-background py-2 px-4 border rounded-full shadow-lg">
|
||||
<div className='text-sm'>
|
||||
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 animate-in duration-300 fade-in slide-in-from-bottom-4">
|
||||
<div className="flex flex-row items-center justify-between gap-10 rounded-full border bg-background px-4 py-2 shadow-lg">
|
||||
<div className="text-sm">
|
||||
{selectedCount} transaction{selectedCount !== 1 ? 's' : ''}{' '}
|
||||
selected
|
||||
</div>
|
||||
|
|
@ -46,12 +44,23 @@ export function BulkActionsBar({
|
|||
/>
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onReEvaluateRules}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Re-evaluate Rules
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDelete}
|
||||
disabled={isUpdating}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950"
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
|
|
@ -74,4 +83,3 @@ export function BulkActionsBar({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { ArrowDown, MoreHorizontal } from 'lucide-react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { ArrowDown, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
import { EncryptedText } from '@/components/encrypted-text';
|
||||
import { CategoryCell } from '@/components/transactions/category-cell';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
|
|
@ -11,11 +13,9 @@ import {
|
|||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { CategoryCell } from '@/components/transactions/category-cell';
|
||||
import { EncryptedText } from '@/components/encrypted-text';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
|
||||
interface CreateColumnsOptions {
|
||||
categories: Category[];
|
||||
|
|
@ -24,6 +24,7 @@ interface CreateColumnsOptions {
|
|||
onEdit: (transaction: DecryptedTransaction) => void;
|
||||
onDelete: (transaction: DecryptedTransaction) => void;
|
||||
onUpdate: (transaction: DecryptedTransaction) => void;
|
||||
onReEvaluateRules: (transaction: DecryptedTransaction) => void;
|
||||
}
|
||||
|
||||
export function createTransactionColumns({
|
||||
|
|
@ -33,6 +34,7 @@ export function createTransactionColumns({
|
|||
onEdit,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
onReEvaluateRules,
|
||||
}: CreateColumnsOptions): ColumnDef<DecryptedTransaction>[] {
|
||||
return [
|
||||
{
|
||||
|
|
@ -43,7 +45,9 @@ export function createTransactionColumns({
|
|||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
|
|
@ -75,8 +79,11 @@ export function createTransactionColumns({
|
|||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="font-medium pl-3">
|
||||
{format(parseISO(row.getValue('transaction_date')), 'PP')}
|
||||
<div className="pl-3 font-medium">
|
||||
{format(
|
||||
parseISO(row.getValue('transaction_date')),
|
||||
'PP',
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
@ -105,7 +112,7 @@ export function createTransactionColumns({
|
|||
cell: ({ row }) => {
|
||||
const transaction = row.original;
|
||||
return (
|
||||
<div className="max-w-[150px] md:max-w-[250px] lg:max-w-[500px] truncate">
|
||||
<div className="max-w-[150px] truncate md:max-w-[250px] lg:max-w-[500px]">
|
||||
<EncryptedText
|
||||
encryptedText={transaction.description}
|
||||
iv={transaction.description_iv}
|
||||
|
|
@ -160,11 +167,7 @@ export function createTransactionColumns({
|
|||
accessorKey: 'amount',
|
||||
meta: { label: 'Amount' },
|
||||
header: () => {
|
||||
return (
|
||||
<div className="w-full text-right">
|
||||
Amount
|
||||
</div>
|
||||
);
|
||||
return <div className="w-full text-right">Amount</div>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const amount = parseFloat(row.getValue('amount'));
|
||||
|
|
@ -176,10 +179,12 @@ export function createTransactionColumns({
|
|||
}).format(amount);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`text-right`}
|
||||
>
|
||||
<span className={`${amount < 0 ? '' : 'bg-green-100/70 dark:bg-green-900'}`}>{formatted}</span>
|
||||
<div className={`text-right`}>
|
||||
<span
|
||||
className={`${amount < 0 ? '' : 'bg-green-100/70 dark:bg-green-900'}`}
|
||||
>
|
||||
{formatted}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
@ -200,11 +205,18 @@ export function createTransactionColumns({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => onEdit(transaction)}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onEdit(transaction)}
|
||||
>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant='destructive'
|
||||
onClick={() => onReEvaluateRules(transaction)}
|
||||
>
|
||||
Re-evaluate Rules
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(transaction)}
|
||||
>
|
||||
Delete
|
||||
|
|
@ -216,4 +228,3 @@ export function createTransactionColumns({
|
|||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { categorySyncService } from '@/services/category-sync';
|
|||
import { accountSyncService } from '@/services/account-sync';
|
||||
import { bankSyncService } from '@/services/bank-sync';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { checkDatabaseVersion } from '@/lib/db-migration-helper';
|
||||
|
||||
export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error';
|
||||
|
||||
|
|
@ -89,11 +91,12 @@ export function SyncProvider({
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const [categoriesResult, accountsResult, banksResult, transactionsResult] =
|
||||
const [categoriesResult, accountsResult, banksResult, automationRulesResult, transactionsResult] =
|
||||
await Promise.all([
|
||||
categorySyncService.sync(),
|
||||
accountSyncService.sync(),
|
||||
bankSyncService.sync(),
|
||||
automationRuleSyncService.sync(),
|
||||
transactionSyncService.sync(),
|
||||
]);
|
||||
|
||||
|
|
@ -101,6 +104,7 @@ export function SyncProvider({
|
|||
...categoriesResult.errors,
|
||||
...accountsResult.errors,
|
||||
...banksResult.errors,
|
||||
...automationRulesResult.errors,
|
||||
...transactionsResult.errors,
|
||||
];
|
||||
|
||||
|
|
@ -152,6 +156,16 @@ export function SyncProvider({
|
|||
return;
|
||||
}
|
||||
|
||||
checkDatabaseVersion().then(({ needsRefresh, missingStores }) => {
|
||||
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();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated]);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { cn, isSameUrl, resolveUrl } from '@/lib/utils';
|
|||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController';
|
||||
import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController';
|
||||
import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
|
||||
import { edit as editAccount } from '@/routes/account';
|
||||
import { edit as editDeleteAccount } from '@/routes/delete-account';
|
||||
import { type NavItem } from '@/types';
|
||||
|
|
@ -22,6 +23,11 @@ const sidebarNavItems: NavItem[] = [
|
|||
href: accountsIndex(),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
title: 'Automation rules',
|
||||
href: automationRulesIndex(),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
title: 'Categories',
|
||||
href: categoriesIndex(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { indexedDBService } from './indexeddb';
|
||||
|
||||
export async function checkDatabaseVersion(): Promise<{
|
||||
needsRefresh: boolean;
|
||||
missingStores: string[];
|
||||
}> {
|
||||
const requiredStores = [
|
||||
'categories',
|
||||
'accounts',
|
||||
'banks',
|
||||
'automation_rules',
|
||||
'transactions',
|
||||
'sync_metadata',
|
||||
'pending_changes',
|
||||
];
|
||||
|
||||
try {
|
||||
const db = await indexedDBService.init();
|
||||
const existingStores = Array.from(db.objectStoreNames);
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export function consoleDebug(...args: unknown[]): void {
|
||||
const isDebugEnabled = localStorage.getItem('debug') === 'true';
|
||||
const isLocalhost = window.location.hostname === 'localhost';
|
||||
const isTestDomain = window.location.hostname.includes('.test');
|
||||
|
||||
if (isDebugEnabled || isLocalhost || isTestDomain) {
|
||||
console.log('[DEBUG]', ...args);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
const DB_NAME = 'whisper_money';
|
||||
const DB_VERSION = 2;
|
||||
const DB_VERSION = 3;
|
||||
|
||||
// Force database recreation if needed
|
||||
export async function resetDatabase(): Promise<void> {
|
||||
|
|
@ -30,7 +30,7 @@ export interface IndexedDBRecord {
|
|||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type StoreName = 'categories' | 'accounts' | 'banks' | 'transactions';
|
||||
export type StoreName = 'categories' | 'accounts' | 'banks' | 'automation_rules' | 'transactions';
|
||||
|
||||
class IndexedDBService {
|
||||
private db: IDBDatabase | null = null;
|
||||
|
|
@ -101,6 +101,21 @@ class IndexedDBService {
|
|||
});
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('automation_rules')) {
|
||||
const automationRuleStore = db.createObjectStore('automation_rules', {
|
||||
keyPath: 'id',
|
||||
});
|
||||
automationRuleStore.createIndex('user_id', 'user_id', {
|
||||
unique: false,
|
||||
});
|
||||
automationRuleStore.createIndex('priority', 'priority', {
|
||||
unique: false,
|
||||
});
|
||||
automationRuleStore.createIndex('updated_at', 'updated_at', {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('transactions')) {
|
||||
const transactionStore = db.createObjectStore('transactions', {
|
||||
keyPath: 'id',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import { consoleDebug } from '@/lib/debug';
|
||||
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 jsonLogic from 'json-logic-js';
|
||||
|
||||
export interface RuleEvaluationResult {
|
||||
rule: AutomationRule;
|
||||
categoryId: number | null;
|
||||
note: string | null;
|
||||
noteIv: string | null;
|
||||
}
|
||||
|
||||
export interface TransactionData {
|
||||
description: string;
|
||||
amount: number;
|
||||
transaction_date: string;
|
||||
bank_name: string;
|
||||
account_name: string;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
export function prepareTransactionData(
|
||||
transaction: DecryptedTransaction,
|
||||
accounts: Account[],
|
||||
banks: Bank[],
|
||||
categories: Category[],
|
||||
): TransactionData {
|
||||
const account = accounts.find((a) => a.id === transaction.account_id);
|
||||
const bank = account?.bank?.id
|
||||
? banks.find((b) => b.id === account.bank.id)
|
||||
: undefined;
|
||||
const category = transaction.category_id
|
||||
? categories.find((c) => c.id === transaction.category_id)
|
||||
: null;
|
||||
|
||||
return {
|
||||
description: transaction.decryptedDescription || '',
|
||||
amount: parseFloat(transaction.amount),
|
||||
transaction_date: transaction.transaction_date,
|
||||
bank_name: bank?.name || '',
|
||||
account_name: account?.name || '',
|
||||
category: category?.name || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateRules(
|
||||
transaction: DecryptedTransaction,
|
||||
rules: AutomationRule[],
|
||||
categories: Category[],
|
||||
accounts: Account[],
|
||||
banks: Bank[],
|
||||
): RuleEvaluationResult | null {
|
||||
const sortedRules = [...rules].sort((a, b) => a.priority - b.priority);
|
||||
|
||||
const transactionData = prepareTransactionData(
|
||||
transaction,
|
||||
accounts,
|
||||
banks,
|
||||
categories,
|
||||
);
|
||||
|
||||
consoleDebug('[Rule Engine] Transaction data prepared:', transactionData);
|
||||
consoleDebug(`[Rule Engine] Evaluating ${sortedRules.length} rules`);
|
||||
|
||||
for (const rule of sortedRules) {
|
||||
try {
|
||||
consoleDebug(
|
||||
`[Rule Engine] Evaluating rule #${rule.id}: "${rule.title}"`,
|
||||
);
|
||||
consoleDebug('[Rule Engine] Rule JSON:', rule.rules_json);
|
||||
|
||||
const result = jsonLogic.apply(rule.rules_json, transactionData);
|
||||
|
||||
consoleDebug(`[Rule Engine] Rule #${rule.id} result:`, result);
|
||||
|
||||
if (result === true) {
|
||||
consoleDebug(`[Rule Engine] ✓ Rule #${rule.id} matched!`);
|
||||
return {
|
||||
rule,
|
||||
categoryId: rule.action_category_id,
|
||||
note: rule.action_note,
|
||||
noteIv: rule.action_note_iv,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
consoleDebug(
|
||||
`[Rule Engine] ❌ Error evaluating rule ${rule.id}:`,
|
||||
error,
|
||||
);
|
||||
console.error(`Error evaluating rule ${rule.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
consoleDebug('[Rule Engine] No rules matched');
|
||||
return null;
|
||||
}
|
||||
|
||||
export function evaluateRulesForTransactions(
|
||||
transactions: DecryptedTransaction[],
|
||||
rules: AutomationRule[],
|
||||
categories: Category[],
|
||||
accounts: Account[],
|
||||
banks: Bank[],
|
||||
): Map<string, RuleEvaluationResult> {
|
||||
const results = new Map<string, RuleEvaluationResult>();
|
||||
|
||||
for (const transaction of transactions) {
|
||||
const result = evaluateRules(
|
||||
transaction,
|
||||
rules,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
results.set(transaction.id, result);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import { CreateAutomationRuleDialog } from '@/components/automation-rules/create-automation-rule-dialog';
|
||||
import { EditAutomationRuleDialog } from '@/components/automation-rules/edit-automation-rule-dialog';
|
||||
import { DeleteAutomationRuleDialog } from '@/components/automation-rules/delete-automation-rule-dialog';
|
||||
import {
|
||||
type AutomationRule,
|
||||
formatRuleActions,
|
||||
getRuleActions,
|
||||
} from '@/types/automation-rule';
|
||||
import { getCategoryColorClasses } from '@/types/category';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { useSyncContext } from '@/contexts/sync-context';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Automation rules settings',
|
||||
href: automationRulesIndex().url,
|
||||
},
|
||||
];
|
||||
|
||||
function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
variant="destructive"
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<EditAutomationRuleDialog
|
||||
rule={rule}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
<DeleteAutomationRuleDialog
|
||||
rule={rule}
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AutomationRules() {
|
||||
const { syncStatus } = useSyncContext();
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const [rules, setRules] = useState<AutomationRule[]>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
{},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadRules = async () => {
|
||||
const data = await automationRuleSyncService.getAll();
|
||||
setRules(data);
|
||||
};
|
||||
|
||||
loadRules();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncStatus === 'success') {
|
||||
const reloadRules = async () => {
|
||||
const data = await automationRuleSyncService.getAll();
|
||||
setRules(data);
|
||||
};
|
||||
reloadRules();
|
||||
}
|
||||
}, [syncStatus]);
|
||||
|
||||
const columns: ColumnDef<AutomationRule>[] = [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
Title
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="pl-3 font-medium">
|
||||
{row.getValue('title')}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions_display',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
const rule = row.original;
|
||||
const actions = getRuleActions(rule);
|
||||
|
||||
if (actions.type === 'both') {
|
||||
const colorClasses = actions.category
|
||||
? getCategoryColorClasses(actions.category.color)
|
||||
: null;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{colorClasses && (
|
||||
<Badge
|
||||
className={`${colorClasses.bg} ${colorClasses.text}`}
|
||||
>
|
||||
{actions.category?.name}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
and add note
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (actions.type === 'category' && actions.category) {
|
||||
const colorClasses = getCategoryColorClasses(
|
||||
actions.category.color,
|
||||
);
|
||||
return (
|
||||
<Badge
|
||||
className={`${colorClasses.bg} ${colorClasses.text}`}
|
||||
>
|
||||
{actions.category.name}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Add note
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => <AutomationRuleActions rule={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: rules,
|
||||
columns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Automation rules settings" />
|
||||
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
<HeadingSmall
|
||||
title="Automation rules settings"
|
||||
description="Manage your transaction automation rules"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Input
|
||||
placeholder="Filter rules..."
|
||||
value={
|
||||
(table
|
||||
.getColumn('title')
|
||||
?.getFilterValue() as string) ?? ''
|
||||
}
|
||||
onChange={(event) =>
|
||||
table
|
||||
.getColumn('title')
|
||||
?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<CreateAutomationRuleDialog disabled={!isKeySet} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map(
|
||||
(header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
'selected'
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No automation rules found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<div className="text-muted-foreground flex-1 text-sm">
|
||||
{table.getFilteredRowModel().rows.length} rule(s)
|
||||
total.
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import {
|
||||
ColumnFiltersState,
|
||||
|
|
@ -9,18 +8,15 @@ import {
|
|||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { parseISO, isWithinInterval } from 'date-fns';
|
||||
import { isWithinInterval, parseISO } from 'date-fns';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
||||
import { DataTable } from '@/components/ui/data-table';
|
||||
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import { DataTablePagination } from '@/components/ui/data-table-pagination';
|
||||
import { DataTableViewOptions } from '@/components/ui/data-table-view-options';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { TransactionFilters } from '@/components/transactions/transaction-filters';
|
||||
import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar';
|
||||
import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog';
|
||||
import { createTransactionColumns } from '@/components/transactions/transaction-columns';
|
||||
import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar';
|
||||
import { TransactionFilters } from '@/components/transactions/transaction-filters';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -31,18 +27,25 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { type Category } from '@/types/category';
|
||||
import { DataTable } from '@/components/ui/data-table';
|
||||
import { DataTablePagination } from '@/components/ui/data-table-pagination';
|
||||
import { DataTableViewOptions } from '@/components/ui/data-table-view-options';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
||||
import { decrypt, encrypt, importKey } from '@/lib/crypto';
|
||||
import { consoleDebug } from '@/lib/debug';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { evaluateRules } from '@/lib/rule-engine';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type Category } from '@/types/category';
|
||||
import {
|
||||
type DecryptedTransaction,
|
||||
type TransactionFilters as Filters,
|
||||
} from '@/types/transaction';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { decrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
|
|
@ -57,17 +60,36 @@ interface Props {
|
|||
banks: Bank[];
|
||||
}
|
||||
|
||||
const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility';
|
||||
|
||||
function getInitialColumnVisibility(): VisibilityState {
|
||||
try {
|
||||
const stored = localStorage.getItem(COLUMN_VISIBILITY_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to load column visibility from localStorage:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return { account: false };
|
||||
}
|
||||
|
||||
export default function Transactions({ categories, accounts, banks }: Props) {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const [transactions, setTransactions] = useState<DecryptedTransaction[]>([]);
|
||||
const [transactions, setTransactions] = useState<DecryptedTransaction[]>(
|
||||
[],
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'transaction_date', desc: true },
|
||||
]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
account: false,
|
||||
});
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
getInitialColumnVisibility(),
|
||||
);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
dateFrom: null,
|
||||
|
|
@ -85,6 +107,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
||||
const [isBulkUpdating, setIsBulkUpdating] = useState(false);
|
||||
const [isReEvaluating, setIsReEvaluating] = useState(false);
|
||||
const [displayedCount, setDisplayedCount] = useState(25);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -109,7 +132,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
: updatedTransaction.bank,
|
||||
category:
|
||||
updatedTransaction.category === undefined
|
||||
? transaction.category ?? null
|
||||
? (transaction.category ?? null)
|
||||
: updatedTransaction.category,
|
||||
};
|
||||
}),
|
||||
|
|
@ -198,12 +221,89 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
}),
|
||||
);
|
||||
|
||||
setTransactions(
|
||||
decrypted.filter(
|
||||
(transaction): transaction is DecryptedTransaction =>
|
||||
transaction !== null,
|
||||
),
|
||||
const validTransactions = decrypted.filter(
|
||||
(transaction): transaction is DecryptedTransaction =>
|
||||
transaction !== null,
|
||||
);
|
||||
|
||||
const rules = await automationRuleSyncService.getAll();
|
||||
|
||||
if (rules.length > 0 && key) {
|
||||
const transactionsToUpdate: Array<{
|
||||
id: string;
|
||||
category_id: number | null;
|
||||
notes: string | null;
|
||||
notes_iv: string | null;
|
||||
}> = [];
|
||||
|
||||
for (const transaction of validTransactions) {
|
||||
if (!transaction.category_id) {
|
||||
const result = evaluateRules(
|
||||
transaction,
|
||||
rules,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
let finalNotes = transaction.notes;
|
||||
let finalNotesIv = transaction.notes_iv;
|
||||
|
||||
if (result.note && result.noteIv) {
|
||||
if (transaction.decryptedNotes) {
|
||||
const combinedNote = `${transaction.decryptedNotes}\n${await decrypt(result.note, key, result.noteIv)}`;
|
||||
const encrypted = await encrypt(
|
||||
combinedNote,
|
||||
key,
|
||||
);
|
||||
finalNotes = encrypted.encrypted;
|
||||
finalNotesIv = encrypted.iv;
|
||||
} else {
|
||||
finalNotes = result.note;
|
||||
finalNotesIv = result.noteIv;
|
||||
}
|
||||
}
|
||||
|
||||
transactionsToUpdate.push({
|
||||
id: transaction.id,
|
||||
category_id: result.categoryId,
|
||||
notes: finalNotes,
|
||||
notes_iv: finalNotesIv,
|
||||
});
|
||||
|
||||
transaction.category_id = result.categoryId;
|
||||
if (result.categoryId) {
|
||||
transaction.category =
|
||||
categories.find(
|
||||
(c) => c.id === result.categoryId,
|
||||
) || null;
|
||||
}
|
||||
if (finalNotes && finalNotesIv) {
|
||||
transaction.notes = finalNotes;
|
||||
transaction.notes_iv = finalNotesIv;
|
||||
transaction.decryptedNotes = await decrypt(
|
||||
finalNotes,
|
||||
key,
|
||||
finalNotesIv,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (transactionsToUpdate.length > 0) {
|
||||
for (const update of transactionsToUpdate) {
|
||||
await transactionSyncService.update(update.id, {
|
||||
category_id: update.category_id,
|
||||
notes: update.notes,
|
||||
notes_iv: update.notes_iv,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTransactions(validTransactions);
|
||||
} catch (error) {
|
||||
console.error('Failed to load transactions:', error);
|
||||
} finally {
|
||||
|
|
@ -215,6 +315,20 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
loadTransactions();
|
||||
}, [loadTransactions]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
COLUMN_VISIBILITY_KEY,
|
||||
JSON.stringify(columnVisibility),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to save column visibility to localStorage:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}, [columnVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
async function reDecryptTransactions() {
|
||||
if (transactions.length === 0) {
|
||||
|
|
@ -335,10 +449,9 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
|
||||
if (filters.searchText && isKeySet) {
|
||||
const searchLower = filters.searchText.toLowerCase();
|
||||
const matchesDescription =
|
||||
transaction.decryptedDescription
|
||||
.toLowerCase()
|
||||
.includes(searchLower);
|
||||
const matchesDescription = transaction.decryptedDescription
|
||||
.toLowerCase()
|
||||
.includes(searchLower);
|
||||
const matchesNotes =
|
||||
transaction.decryptedNotes
|
||||
?.toLowerCase()
|
||||
|
|
@ -357,6 +470,289 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
return filteredTransactions.slice(0, displayedCount);
|
||||
}, [filteredTransactions, displayedCount]);
|
||||
|
||||
async function handleReEvaluateRules(transaction: DecryptedTransaction) {
|
||||
consoleDebug('=== Re-evaluating rules for single transaction ===');
|
||||
consoleDebug('Transaction:', {
|
||||
id: transaction.id,
|
||||
description: transaction.decryptedDescription,
|
||||
amount: transaction.amount,
|
||||
currentCategory: transaction.category?.name || 'None',
|
||||
});
|
||||
|
||||
setIsReEvaluating(true);
|
||||
try {
|
||||
const keyString = getStoredKey();
|
||||
if (!keyString || !isKeySet) {
|
||||
consoleDebug('❌ Encryption key not set');
|
||||
console.error('Encryption key not set');
|
||||
return;
|
||||
}
|
||||
consoleDebug('✓ Encryption key found');
|
||||
|
||||
const key = await importKey(keyString);
|
||||
const rules = await automationRuleSyncService.getAll();
|
||||
consoleDebug(`Found ${rules.length} automation rules`);
|
||||
|
||||
if (rules.length === 0) {
|
||||
consoleDebug('❌ No rules to evaluate');
|
||||
return;
|
||||
}
|
||||
|
||||
consoleDebug('Evaluating rules against transaction...');
|
||||
const result = evaluateRules(
|
||||
transaction,
|
||||
rules,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
);
|
||||
|
||||
consoleDebug('Rule evaluation result:', result);
|
||||
|
||||
if (result) {
|
||||
consoleDebug('✓ Rule matched! Applying changes...');
|
||||
let finalNotes = transaction.notes;
|
||||
let finalNotesIv = transaction.notes_iv;
|
||||
|
||||
if (result.note && result.noteIv) {
|
||||
consoleDebug('Adding note from rule');
|
||||
if (transaction.decryptedNotes) {
|
||||
const combinedNote = `${transaction.decryptedNotes}\n${await decrypt(result.note, key, result.noteIv)}`;
|
||||
const encrypted = await encrypt(combinedNote, key);
|
||||
finalNotes = encrypted.encrypted;
|
||||
finalNotesIv = encrypted.iv;
|
||||
consoleDebug('Combined existing notes with rule note');
|
||||
} else {
|
||||
finalNotes = result.note;
|
||||
finalNotesIv = result.noteIv;
|
||||
consoleDebug('Set rule note as new note');
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
category_id: result.categoryId,
|
||||
notes: finalNotes,
|
||||
notes_iv: finalNotesIv,
|
||||
};
|
||||
consoleDebug('Updating transaction with:', updateData);
|
||||
|
||||
await transactionSyncService.update(transaction.id, updateData);
|
||||
consoleDebug('✓ Transaction updated in IndexedDB');
|
||||
|
||||
const selectedCategory = result.categoryId
|
||||
? categories.find((c) => c.id === result.categoryId) || null
|
||||
: null;
|
||||
|
||||
let decryptedNotes = transaction.decryptedNotes;
|
||||
if (finalNotes && finalNotesIv) {
|
||||
decryptedNotes = await decrypt(
|
||||
finalNotes,
|
||||
key,
|
||||
finalNotesIv,
|
||||
);
|
||||
}
|
||||
|
||||
const updatedTransaction = {
|
||||
...transaction,
|
||||
category_id: result.categoryId,
|
||||
category: selectedCategory,
|
||||
notes: finalNotes,
|
||||
notes_iv: finalNotesIv,
|
||||
decryptedNotes,
|
||||
};
|
||||
consoleDebug('Updating UI state with:', {
|
||||
id: updatedTransaction.id,
|
||||
newCategory: selectedCategory?.name || 'None',
|
||||
hasNotes: !!decryptedNotes,
|
||||
});
|
||||
|
||||
updateTransaction(updatedTransaction);
|
||||
consoleDebug('✓ UI state updated successfully');
|
||||
} else {
|
||||
consoleDebug('❌ No rules matched this transaction');
|
||||
}
|
||||
} catch (error) {
|
||||
consoleDebug('❌ Error during re-evaluation:', error);
|
||||
console.error('Failed to re-evaluate rules:', error);
|
||||
} finally {
|
||||
setIsReEvaluating(false);
|
||||
consoleDebug('=== Re-evaluation complete ===');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkReEvaluateRules() {
|
||||
const selectedIds = Object.keys(rowSelection);
|
||||
consoleDebug('=== Re-evaluating rules for bulk transactions ===');
|
||||
consoleDebug(`Selected ${selectedIds.length} transactions`);
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
consoleDebug('❌ No transactions selected');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsReEvaluating(true);
|
||||
try {
|
||||
const keyString = getStoredKey();
|
||||
if (!keyString || !isKeySet) {
|
||||
consoleDebug('❌ Encryption key not set');
|
||||
console.error('Encryption key not set');
|
||||
return;
|
||||
}
|
||||
consoleDebug('✓ Encryption key found');
|
||||
|
||||
const key = await importKey(keyString);
|
||||
const rules = await automationRuleSyncService.getAll();
|
||||
consoleDebug(`Found ${rules.length} automation rules`);
|
||||
|
||||
if (rules.length === 0) {
|
||||
consoleDebug('❌ No rules to evaluate');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedTransactions = transactions.filter((t) =>
|
||||
selectedIds.includes(t.id),
|
||||
);
|
||||
consoleDebug(
|
||||
'Processing transactions:',
|
||||
selectedTransactions.map((t) => ({
|
||||
id: t.id,
|
||||
description: t.decryptedDescription,
|
||||
currentCategory: t.category?.name || 'None',
|
||||
})),
|
||||
);
|
||||
|
||||
const updates: Array<{
|
||||
transaction: DecryptedTransaction;
|
||||
categoryId: number | null;
|
||||
category: Category | null;
|
||||
notes: string | null;
|
||||
notesIv: string | null;
|
||||
decryptedNotes: string | null;
|
||||
}> = [];
|
||||
|
||||
for (const transaction of selectedTransactions) {
|
||||
consoleDebug(`\nEvaluating transaction ${transaction.id}...`);
|
||||
const result = evaluateRules(
|
||||
transaction,
|
||||
rules,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
);
|
||||
|
||||
consoleDebug('Rule evaluation result:', result);
|
||||
|
||||
if (result) {
|
||||
consoleDebug('✓ Rule matched! Applying changes...');
|
||||
let finalNotes = transaction.notes;
|
||||
let finalNotesIv = transaction.notes_iv;
|
||||
|
||||
if (result.note && result.noteIv) {
|
||||
consoleDebug('Adding note from rule');
|
||||
if (transaction.decryptedNotes) {
|
||||
const combinedNote = `${transaction.decryptedNotes}\n${await decrypt(result.note, key, result.noteIv)}`;
|
||||
const encrypted = await encrypt(combinedNote, key);
|
||||
finalNotes = encrypted.encrypted;
|
||||
finalNotesIv = encrypted.iv;
|
||||
consoleDebug(
|
||||
'Combined existing notes with rule note',
|
||||
);
|
||||
} else {
|
||||
finalNotes = result.note;
|
||||
finalNotesIv = result.noteIv;
|
||||
consoleDebug('Set rule note as new note');
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
category_id: result.categoryId,
|
||||
notes: finalNotes,
|
||||
notes_iv: finalNotesIv,
|
||||
};
|
||||
consoleDebug('Updating transaction with:', updateData);
|
||||
|
||||
await transactionSyncService.update(
|
||||
transaction.id,
|
||||
updateData,
|
||||
);
|
||||
consoleDebug('✓ Transaction updated in IndexedDB');
|
||||
|
||||
const selectedCategory = result.categoryId
|
||||
? categories.find((c) => c.id === result.categoryId) ||
|
||||
null
|
||||
: null;
|
||||
|
||||
let decryptedNotes = transaction.decryptedNotes;
|
||||
if (finalNotes && finalNotesIv) {
|
||||
decryptedNotes = await decrypt(
|
||||
finalNotes,
|
||||
key,
|
||||
finalNotesIv,
|
||||
);
|
||||
}
|
||||
|
||||
updates.push({
|
||||
transaction,
|
||||
categoryId: result.categoryId,
|
||||
category: selectedCategory,
|
||||
notes: finalNotes,
|
||||
notesIv: finalNotesIv,
|
||||
decryptedNotes,
|
||||
});
|
||||
consoleDebug(
|
||||
`✓ Queued update for transaction ${transaction.id}`,
|
||||
);
|
||||
} else {
|
||||
consoleDebug(
|
||||
`❌ No rules matched transaction ${transaction.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
consoleDebug(`\nApplying ${updates.length} updates to UI state...`);
|
||||
if (updates.length > 0) {
|
||||
setTransactions((previous) =>
|
||||
previous.map((transaction) => {
|
||||
const update = updates.find(
|
||||
(u) => u.transaction.id === transaction.id,
|
||||
);
|
||||
if (update) {
|
||||
consoleDebug(
|
||||
`Updating UI for transaction ${transaction.id}:`,
|
||||
{
|
||||
newCategory:
|
||||
update.category?.name || 'None',
|
||||
hasNotes: !!update.decryptedNotes,
|
||||
},
|
||||
);
|
||||
return {
|
||||
...transaction,
|
||||
category_id: update.categoryId,
|
||||
category: update.category,
|
||||
notes: update.notes,
|
||||
notes_iv: update.notesIv,
|
||||
decryptedNotes: update.decryptedNotes,
|
||||
};
|
||||
}
|
||||
return transaction;
|
||||
}),
|
||||
);
|
||||
consoleDebug('✓ UI state updated successfully');
|
||||
} else {
|
||||
consoleDebug('❌ No updates to apply');
|
||||
}
|
||||
|
||||
consoleDebug('Clearing selection...');
|
||||
setRowSelection({});
|
||||
} catch (error) {
|
||||
consoleDebug('❌ Error during bulk re-evaluation:', error);
|
||||
console.error('Failed to re-evaluate rules:', error);
|
||||
} finally {
|
||||
setIsReEvaluating(false);
|
||||
consoleDebug('=== Bulk re-evaluation complete ===');
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createTransactionColumns({
|
||||
|
|
@ -366,6 +762,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
onEdit: setEditTransaction,
|
||||
onDelete: setDeleteTransaction,
|
||||
onUpdate: updateTransaction,
|
||||
onReEvaluateRules: handleReEvaluateRules,
|
||||
}),
|
||||
[accounts, banks, categories, updateTransaction],
|
||||
);
|
||||
|
|
@ -392,7 +789,9 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
|
||||
const loadMore = useCallback(() => {
|
||||
if (displayedCount < filteredTransactions.length) {
|
||||
setDisplayedCount((prev) => Math.min(prev + 25, filteredTransactions.length));
|
||||
setDisplayedCount((prev) =>
|
||||
Math.min(prev + 25, filteredTransactions.length),
|
||||
);
|
||||
}
|
||||
}, [displayedCount, filteredTransactions.length]);
|
||||
|
||||
|
|
@ -403,7 +802,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
loadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
const currentTarget = observerTarget.current;
|
||||
|
|
@ -507,7 +906,9 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
try {
|
||||
await transactionSyncService.deleteMany(selectedIds);
|
||||
setTransactions((previous) =>
|
||||
previous.filter((transaction) => !selectedIds.includes(transaction.id)),
|
||||
previous.filter(
|
||||
(transaction) => !selectedIds.includes(transaction.id),
|
||||
),
|
||||
);
|
||||
setDeleteTransaction(null);
|
||||
setRowSelection({});
|
||||
|
|
@ -554,19 +955,21 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
<Skeleton className="h-5 w-16 justify-self-end" />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-6 gap-4 p-4"
|
||||
>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-20 justify-self-end" />
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: 6 }).map(
|
||||
(_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-6 gap-4 p-4"
|
||||
>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-20 justify-self-end" />
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -610,7 +1013,8 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Delete Transaction{Object.keys(rowSelection).length > 1 ? 's' : ''}
|
||||
Delete Transaction
|
||||
{Object.keys(rowSelection).length > 1 ? 's' : ''}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{Object.keys(rowSelection).length > 1
|
||||
|
|
@ -619,7 +1023,9 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting || isBulkDeleting}>
|
||||
<AlertDialogCancel
|
||||
disabled={isDeleting || isBulkDeleting}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
|
|
@ -631,7 +1037,9 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
disabled={isDeleting || isBulkDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isDeleting || isBulkDeleting ? 'Deleting...' : 'Delete'}
|
||||
{isDeleting || isBulkDeleting
|
||||
? 'Deleting...'
|
||||
: 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
@ -642,10 +1050,10 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
categories={categories}
|
||||
onCategoryChange={handleBulkCategoryChange}
|
||||
onDelete={handleBulkDeleteClick}
|
||||
onReEvaluateRules={handleBulkReEvaluateRules}
|
||||
onClear={handleClearSelection}
|
||||
isUpdating={isBulkUpdating}
|
||||
isUpdating={isBulkUpdating || isReEvaluating}
|
||||
/>
|
||||
</AppSidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { SyncManager } from '@/lib/sync-manager';
|
||||
import type { AutomationRule } from '@/types/automation-rule';
|
||||
|
||||
class AutomationRuleSyncService {
|
||||
private syncManager: SyncManager;
|
||||
|
||||
constructor() {
|
||||
this.syncManager = new SyncManager({
|
||||
storeName: 'automation_rules',
|
||||
endpoint: '/api/sync/automation-rules',
|
||||
});
|
||||
}
|
||||
|
||||
async sync() {
|
||||
return await this.syncManager.sync();
|
||||
}
|
||||
|
||||
async getAll(): Promise<AutomationRule[]> {
|
||||
const rules = await this.syncManager.getAll<AutomationRule>();
|
||||
return rules.map((rule) => ({
|
||||
...rule,
|
||||
rules_json:
|
||||
typeof rule.rules_json === 'string'
|
||||
? JSON.parse(rule.rules_json)
|
||||
: rule.rules_json,
|
||||
}));
|
||||
}
|
||||
|
||||
async getById(id: number): Promise<AutomationRule | null> {
|
||||
const rule = await this.syncManager.getById<AutomationRule>(id);
|
||||
if (!rule) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...rule,
|
||||
rules_json:
|
||||
typeof rule.rules_json === 'string'
|
||||
? JSON.parse(rule.rules_json)
|
||||
: rule.rules_json,
|
||||
};
|
||||
}
|
||||
|
||||
async create(data: Omit<AutomationRule, 'id'>): Promise<AutomationRule> {
|
||||
return await this.syncManager.createLocal<AutomationRule>(
|
||||
data as Omit<AutomationRule, 'id'> & {
|
||||
id?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<AutomationRule>): Promise<void> {
|
||||
await this.syncManager.updateLocal<AutomationRule>(id, data);
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
await this.syncManager.deleteLocal(id);
|
||||
}
|
||||
|
||||
async getLastSyncTime(): Promise<string | null> {
|
||||
return await this.syncManager.getLastSyncTime();
|
||||
}
|
||||
|
||||
isSyncing(): boolean {
|
||||
return this.syncManager.isSyncing();
|
||||
}
|
||||
}
|
||||
|
||||
export const automationRuleSyncService = new AutomationRuleSyncService();
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import type { Category } from './category';
|
||||
|
||||
export interface AutomationRule {
|
||||
id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
priority: number;
|
||||
rules_json: Record<string, any>;
|
||||
action_category_id: number | null;
|
||||
action_note: string | null;
|
||||
action_note_iv: string | null;
|
||||
category?: Category;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
export interface RuleAction {
|
||||
type: 'category' | 'note' | 'both';
|
||||
category?: Category;
|
||||
hasNote: boolean;
|
||||
}
|
||||
|
||||
export function getRuleActions(rule: AutomationRule): RuleAction {
|
||||
const hasCategory = rule.action_category_id !== null;
|
||||
const hasNote = rule.action_note !== null;
|
||||
|
||||
if (hasCategory && hasNote) {
|
||||
return {
|
||||
type: 'both',
|
||||
category: rule.category,
|
||||
hasNote: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasCategory) {
|
||||
return {
|
||||
type: 'category',
|
||||
category: rule.category,
|
||||
hasNote: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'note',
|
||||
hasNote: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRuleActions(rule: AutomationRule): string {
|
||||
const actions = getRuleActions(rule);
|
||||
|
||||
if (actions.type === 'both') {
|
||||
return `${actions.category?.name} and add note`;
|
||||
}
|
||||
|
||||
if (actions.type === 'category') {
|
||||
return actions.category?.name || '';
|
||||
}
|
||||
|
||||
return 'Add note';
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +34,11 @@ Route::middleware('auth')->group(function () {
|
|||
Route::patch('settings/categories/{category}', [CategoryController::class, 'update'])->name('categories.update');
|
||||
Route::delete('settings/categories/{category}', [CategoryController::class, 'destroy'])->name('categories.destroy');
|
||||
|
||||
Route::get('settings/automation-rules', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'index'])->name('automation-rules.index');
|
||||
Route::post('settings/automation-rules', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'store'])->name('automation-rules.store');
|
||||
Route::patch('settings/automation-rules/{automationRule}', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'update'])->name('automation-rules.update');
|
||||
Route::delete('settings/automation-rules/{automationRule}', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'destroy'])->name('automation-rules.destroy');
|
||||
|
||||
Route::get('settings/appearance', function () {
|
||||
return Inertia::render('settings/appearance');
|
||||
})->name('appearance.edit');
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ Route::middleware(['auth'])->group(function () {
|
|||
Route::get('api/sync/categories', [CategorySyncController::class, 'index']);
|
||||
Route::get('api/sync/accounts', [AccountSyncController::class, 'index']);
|
||||
Route::get('api/sync/banks', [BankSyncController::class, 'index']);
|
||||
Route::get('api/sync/automation-rules', [\App\Http\Controllers\Sync\AutomationRuleSyncController::class, 'index']);
|
||||
Route::get('api/sync/transactions', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'index']);
|
||||
Route::post('api/sync/transactions', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'store']);
|
||||
Route::patch('api/sync/transactions/{transaction}', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'update']);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
<?php
|
||||
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
|
||||
test('user can view their automation rules', function () {
|
||||
$user = User::factory()->create();
|
||||
$rule = AutomationRule::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('automation-rules.index'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('settings/automation-rules')
|
||||
->has('rules', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('user can create an automation rule with category action', function () {
|
||||
$user = User::factory()->create();
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
|
||||
'title' => 'Grocery Rule',
|
||||
'priority' => 10,
|
||||
'rules_json' => json_encode(['in' => ['GROCERY', ['var' => 'description']]]),
|
||||
'action_category_id' => $category->id,
|
||||
'action_note' => null,
|
||||
'action_note_iv' => null,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('automation-rules.index'));
|
||||
$this->assertDatabaseHas('automation_rules', [
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Grocery Rule',
|
||||
'priority' => 10,
|
||||
'action_category_id' => $category->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('user can create an automation rule with note action', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
|
||||
'title' => 'Note Rule',
|
||||
'priority' => 5,
|
||||
'rules_json' => json_encode(['==' => [['var' => 'amount'], 100]]),
|
||||
'action_category_id' => null,
|
||||
'action_note' => 'encrypted_note',
|
||||
'action_note_iv' => 'test_iv',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('automation-rules.index'));
|
||||
$this->assertDatabaseHas('automation_rules', [
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Note Rule',
|
||||
'action_note' => 'encrypted_note',
|
||||
]);
|
||||
});
|
||||
|
||||
test('user can create an automation rule with both actions', function () {
|
||||
$user = User::factory()->create();
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
|
||||
'title' => 'Combined Rule',
|
||||
'priority' => 0,
|
||||
'rules_json' => json_encode(['>' => [['var' => 'amount'], 50]]),
|
||||
'action_category_id' => $category->id,
|
||||
'action_note' => 'encrypted_note',
|
||||
'action_note_iv' => 'test_iv',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('automation-rules.index'));
|
||||
$this->assertDatabaseHas('automation_rules', [
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Combined Rule',
|
||||
'action_category_id' => $category->id,
|
||||
'action_note' => 'encrypted_note',
|
||||
]);
|
||||
});
|
||||
|
||||
test('user cannot create rule without at least one action', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
|
||||
'title' => 'Invalid Rule',
|
||||
'priority' => 0,
|
||||
'rules_json' => json_encode(['>' => [['var' => 'amount'], 50]]),
|
||||
'action_category_id' => null,
|
||||
'action_note' => null,
|
||||
'action_note_iv' => null,
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('action_category_id');
|
||||
});
|
||||
|
||||
test('user cannot create rule with invalid json logic', function () {
|
||||
$user = User::factory()->create();
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
|
||||
'title' => 'Invalid JSON Rule',
|
||||
'priority' => 0,
|
||||
'rules_json' => 'invalid json',
|
||||
'action_category_id' => $category->id,
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('rules_json');
|
||||
});
|
||||
|
||||
test('user can update their automation rule', function () {
|
||||
$user = User::factory()->create();
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
$rule = AutomationRule::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Old Title',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->patch(route('automation-rules.update', $rule), [
|
||||
'title' => 'New Title',
|
||||
'priority' => 20,
|
||||
'rules_json' => json_encode(['==' => [['var' => 'bank_name'], 'Chase']]),
|
||||
'action_category_id' => $category->id,
|
||||
'action_note' => null,
|
||||
'action_note_iv' => null,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('automation-rules.index'));
|
||||
$this->assertDatabaseHas('automation_rules', [
|
||||
'id' => $rule->id,
|
||||
'title' => 'New Title',
|
||||
'priority' => 20,
|
||||
]);
|
||||
});
|
||||
|
||||
test('user cannot update another users automation rule', function () {
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
$rule = AutomationRule::factory()->create(['user_id' => $otherUser->id]);
|
||||
|
||||
$response = $this->actingAs($user)->patch(route('automation-rules.update', $rule), [
|
||||
'title' => 'Hacked Title',
|
||||
'priority' => 0,
|
||||
'rules_json' => json_encode(['==' => [1, 1]]),
|
||||
'action_category_id' => $category->id,
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('user can soft delete their automation rule', function () {
|
||||
$user = User::factory()->create();
|
||||
$rule = AutomationRule::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('automation-rules.destroy', $rule));
|
||||
|
||||
$response->assertRedirect(route('automation-rules.index'));
|
||||
$this->assertSoftDeleted('automation_rules', ['id' => $rule->id]);
|
||||
});
|
||||
|
||||
test('user cannot delete another users automation rule', function () {
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$rule = AutomationRule::factory()->create(['user_id' => $otherUser->id]);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('automation-rules.destroy', $rule));
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas('automation_rules', ['id' => $rule->id]);
|
||||
});
|
||||
|
||||
test('rules are ordered by priority', function () {
|
||||
$user = User::factory()->create();
|
||||
AutomationRule::factory()->create(['user_id' => $user->id, 'priority' => 30]);
|
||||
AutomationRule::factory()->create(['user_id' => $user->id, 'priority' => 10]);
|
||||
AutomationRule::factory()->create(['user_id' => $user->id, 'priority' => 20]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('automation-rules.index'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('rules', 3)
|
||||
->where('rules.0.priority', 10)
|
||||
->where('rules.1.priority', 20)
|
||||
->where('rules.2.priority', 30)
|
||||
);
|
||||
});
|
||||
|
||||
test('user cannot use another users category in rule', function () {
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$otherCategory = Category::factory()->create(['user_id' => $otherUser->id]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
|
||||
'title' => 'Invalid Category Rule',
|
||||
'priority' => 0,
|
||||
'rules_json' => json_encode(['==' => [1, 1]]),
|
||||
'action_category_id' => $otherCategory->id,
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('action_category_id');
|
||||
});
|
||||
Loading…
Reference in New Issue