feat: add transaction labels feature (#24)
## Summary Add a labeling system for transactions that differs from categories in that labels are ephemeral and created on-the-fly when assigned (no pre-creation required). A transaction can have multiple labels (many-to-many relationship), and labels can be used for filtering in the transaction table and as actions in automation rules. <img width="1372" height="484" alt="image" src="https://github.com/user-attachments/assets/fd342b11-dafb-44ed-b818-578e1ea856e6" /> <img width="1373" height="613" alt="image" src="https://github.com/user-attachments/assets/aa5cfb8b-50b7-4101-a872-54904924f234" /> <img width="1035" height="594" alt="image" src="https://github.com/user-attachments/assets/ffa946b2-b01f-496b-8cb8-ab55ab5b79ed" /> ## Changes ### Backend (Laravel) - Add `labels` table with user_id, name, color, and soft deletes - Add `label_transaction` pivot table for transaction-label many-to-many - Add `automation_rule_labels` pivot table for automation rule-label many-to-many - Create Label model with relationships to User, Transaction, and AutomationRule - Add LabelController for CRUD operations (returns JSON for on-the-fly creation) - Add LabelSyncController for frontend sync - Add LabelPolicy for authorization - Update TransactionController to handle bulk label updates - Update AutomationRuleController to handle label actions - Add validation for label_ids in transactions and automation rules ### Frontend (React/TypeScript) - Add Label type and color utilities - Add labels table to IndexedDB (Dexie version 6) - Create label-sync service with findOrCreate functionality - Create LabelCombobox component with multi-select and create-on-type - Add labels column to transactions table - Add labels filter to transaction filters - Add bulk label assignment in bulk actions bar - Add labels action in create automation rule dialog - Update rule engine to include labels in evaluation results ## Testing - 11 feature tests for label CRUD - 3 tests for label sync - All 241 feature tests pass ## Screenshots N/A - UI changes can be reviewed in browser
This commit is contained in:
parent
ec6b6d04cf
commit
4b5d65ba03
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum LabelColor: string
|
||||
{
|
||||
case Amber = 'amber';
|
||||
case Blue = 'blue';
|
||||
case Cyan = 'cyan';
|
||||
case Emerald = 'emerald';
|
||||
case Fuchsia = 'fuchsia';
|
||||
case Gray = 'gray';
|
||||
case Green = 'green';
|
||||
case Indigo = 'indigo';
|
||||
case Lime = 'lime';
|
||||
case Neutral = 'neutral';
|
||||
case Orange = 'orange';
|
||||
case Pink = 'pink';
|
||||
case Purple = 'purple';
|
||||
case Red = 'red';
|
||||
case Rose = 'rose';
|
||||
case Slate = 'slate';
|
||||
case Stone = 'stone';
|
||||
case Teal = 'teal';
|
||||
case Violet = 'violet';
|
||||
case Yellow = 'yellow';
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ class AutomationRuleController extends Controller
|
|||
{
|
||||
$rules = auth()->user()
|
||||
->automationRules()
|
||||
->with('category:id,name,icon,color')
|
||||
->with(['category:id,name,icon,color', 'labels:id,name,color'])
|
||||
->orderBy('priority')
|
||||
->get(['id', 'title', 'priority', 'rules_json', 'action_category_id', 'action_note', 'action_note_iv']);
|
||||
|
||||
|
|
@ -36,7 +36,15 @@ class AutomationRuleController extends Controller
|
|||
*/
|
||||
public function store(StoreAutomationRuleRequest $request): RedirectResponse
|
||||
{
|
||||
auth()->user()->automationRules()->create($request->validated());
|
||||
$validated = $request->validated();
|
||||
$labelIds = $validated['action_label_ids'] ?? [];
|
||||
unset($validated['action_label_ids']);
|
||||
|
||||
$rule = auth()->user()->automationRules()->create($validated);
|
||||
|
||||
if (! empty($labelIds)) {
|
||||
$rule->labels()->sync($labelIds);
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
|
|
@ -48,7 +56,12 @@ class AutomationRuleController extends Controller
|
|||
{
|
||||
$this->authorize('update', $automationRule);
|
||||
|
||||
$automationRule->update($request->validated());
|
||||
$validated = $request->validated();
|
||||
$labelIds = $validated['action_label_ids'] ?? [];
|
||||
unset($validated['action_label_ids']);
|
||||
|
||||
$automationRule->update($validated);
|
||||
$automationRule->labels()->sync($labelIds);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\StoreLabelRequest;
|
||||
use App\Http\Requests\Settings\UpdateLabelRequest;
|
||||
use App\Models\Label;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class LabelController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
/**
|
||||
* Show the user's labels settings page.
|
||||
*/
|
||||
public function index(): Response
|
||||
{
|
||||
$labels = auth()->user()
|
||||
->labels()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'color']);
|
||||
|
||||
return Inertia::render('settings/labels', [
|
||||
'labels' => $labels,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created label.
|
||||
*/
|
||||
public function store(StoreLabelRequest $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
$label = auth()->user()->labels()->create($request->validated());
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['data' => $label], 201);
|
||||
}
|
||||
|
||||
return to_route('labels.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified label.
|
||||
*/
|
||||
public function update(UpdateLabelRequest $request, Label $label): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $label);
|
||||
|
||||
$label->update($request->validated());
|
||||
|
||||
return to_route('labels.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete the specified label.
|
||||
*/
|
||||
public function destroy(Label $label): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $label);
|
||||
|
||||
$label->delete();
|
||||
|
||||
return to_route('labels.index');
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ class AutomationRuleSyncController extends Controller
|
|||
{
|
||||
$rules = auth()->user()
|
||||
->automationRules()
|
||||
->with('category:id,name,icon,color')
|
||||
->with(['category:id,name,icon,color', 'labels:id,name,color'])
|
||||
->orderBy('priority')
|
||||
->get();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sync;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Label;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LabelSyncController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Label::query()->where('user_id', $request->user()->id);
|
||||
|
||||
if ($request->has('since')) {
|
||||
$query->where('updated_at', '>', $request->input('since'));
|
||||
}
|
||||
|
||||
$labels = $query->orderBy('name', 'asc')->get();
|
||||
|
||||
return response()->json([
|
||||
'data' => $labels,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ class TransactionSyncController extends Controller
|
|||
}
|
||||
|
||||
$transactions = $query
|
||||
->with('labels:id,name,color')
|
||||
->orderBy('transaction_date', 'desc')
|
||||
->orderBy('updated_at', 'desc')
|
||||
->get();
|
||||
|
|
@ -32,6 +33,8 @@ class TransactionSyncController extends Controller
|
|||
public function store(StoreTransactionRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$labelIds = $data['label_ids'] ?? [];
|
||||
unset($data['label_ids']);
|
||||
|
||||
// Create transaction with provided ID if available
|
||||
$transaction = new Transaction([
|
||||
|
|
@ -47,8 +50,12 @@ class TransactionSyncController extends Controller
|
|||
|
||||
$transaction->save();
|
||||
|
||||
if (! empty($labelIds)) {
|
||||
$transaction->labels()->sync($labelIds);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $transaction,
|
||||
'data' => $transaction->load('labels:id,name,color'),
|
||||
], 201);
|
||||
}
|
||||
|
||||
|
|
@ -62,10 +69,17 @@ class TransactionSyncController extends Controller
|
|||
$data = $request->validated();
|
||||
unset($data['id']); // Don't allow ID changes
|
||||
|
||||
$labelIds = $data['label_ids'] ?? null;
|
||||
unset($data['label_ids']);
|
||||
|
||||
$transaction->update($data);
|
||||
|
||||
if ($labelIds !== null) {
|
||||
$transaction->labels()->sync($labelIds);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $transaction->fresh(),
|
||||
'data' => $transaction->fresh()->load('labels:id,name,color'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use App\Http\Requests\UpdateTransactionRequest;
|
|||
use App\Models\Account;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Category;
|
||||
use App\Models\Label;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
|
@ -42,10 +43,16 @@ class TransactionController extends Controller
|
|||
->orderBy('name')
|
||||
->get(['id', 'name', 'logo']);
|
||||
|
||||
$labels = Label::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'color']);
|
||||
|
||||
return Inertia::render('transactions/index', [
|
||||
'categories' => $categories,
|
||||
'accounts' => $accounts,
|
||||
'banks' => $banks,
|
||||
'labels' => $labels,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -72,10 +79,16 @@ class TransactionController extends Controller
|
|||
->orderBy('name')
|
||||
->get(['id', 'name', 'logo']);
|
||||
|
||||
$labels = Label::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'color']);
|
||||
|
||||
return Inertia::render('transactions/categorize', [
|
||||
'categories' => $categories,
|
||||
'accounts' => $accounts,
|
||||
'banks' => $banks,
|
||||
'labels' => $labels,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -149,16 +162,27 @@ class TransactionController extends Controller
|
|||
$updateData['notes_iv'] = $request->input('notes_iv');
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
$labelIds = $request->input('label_ids');
|
||||
$hasLabelUpdate = $request->has('label_ids');
|
||||
|
||||
if (empty($updateData) && ! $hasLabelUpdate) {
|
||||
return response()->json([
|
||||
'message' => 'No update data provided.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
Transaction::query()
|
||||
->whereIn('id', $transactionIds)
|
||||
->where('user_id', $user->id)
|
||||
->update($updateData);
|
||||
if (! empty($updateData)) {
|
||||
Transaction::query()
|
||||
->whereIn('id', $transactionIds)
|
||||
->where('user_id', $user->id)
|
||||
->update($updateData);
|
||||
}
|
||||
|
||||
if ($hasLabelUpdate) {
|
||||
foreach ($transactions as $transaction) {
|
||||
$transaction->labels()->sync($labelIds ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Transactions updated successfully',
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ class BulkUpdateTransactionsRequest extends FormRequest
|
|||
'category_id' => ['nullable', 'exists:categories,id'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
'notes_iv' => ['nullable', 'string', 'size:16'],
|
||||
'label_ids' => ['nullable', 'array'],
|
||||
'label_ids.*' => ['required', 'string', 'uuid', 'exists:labels,id'],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ class StoreAutomationRuleRequest extends FormRequest
|
|||
],
|
||||
'action_note' => ['nullable', 'string'],
|
||||
'action_note_iv' => ['nullable', 'string', 'required_with:action_note'],
|
||||
'action_label_ids' => ['nullable', 'array'],
|
||||
'action_label_ids.*' => [
|
||||
'required',
|
||||
'string',
|
||||
'uuid',
|
||||
Rule::exists('labels', 'id')->where(function ($query) {
|
||||
$query->where('user_id', auth()->id());
|
||||
}),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -50,8 +59,9 @@ class StoreAutomationRuleRequest extends FormRequest
|
|||
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.');
|
||||
$hasLabels = ! empty($this->action_label_ids);
|
||||
if (! $this->action_category_id && ! $this->action_note && ! $hasLabels) {
|
||||
$validator->errors()->add('action_category_id', 'At least one action (category, note, or labels) must be provided.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Enums\LabelColor;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreLabelRequest 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 [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('labels', 'name')
|
||||
->where('user_id', auth()->id())
|
||||
->whereNull('deleted_at'),
|
||||
],
|
||||
'color' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::enum(LabelColor::class),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,15 @@ class UpdateAutomationRuleRequest extends FormRequest
|
|||
],
|
||||
'action_note' => ['nullable', 'string'],
|
||||
'action_note_iv' => ['nullable', 'string', 'required_with:action_note'],
|
||||
'action_label_ids' => ['nullable', 'array'],
|
||||
'action_label_ids.*' => [
|
||||
'required',
|
||||
'string',
|
||||
'uuid',
|
||||
Rule::exists('labels', 'id')->where(function ($query) {
|
||||
$query->where('user_id', auth()->id());
|
||||
}),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -50,8 +59,9 @@ class UpdateAutomationRuleRequest extends FormRequest
|
|||
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.');
|
||||
$hasLabels = ! empty($this->action_label_ids);
|
||||
if (! $this->action_category_id && ! $this->action_note && ! $hasLabels) {
|
||||
$validator->errors()->add('action_category_id', 'At least one action (category, note, or labels) must be provided.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Enums\LabelColor;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateLabelRequest 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 [
|
||||
'name' => [
|
||||
'sometimes',
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('labels', 'name')
|
||||
->where('user_id', auth()->id())
|
||||
->whereNull('deleted_at')
|
||||
->ignore($this->route('label')),
|
||||
],
|
||||
'color' => [
|
||||
'sometimes',
|
||||
'required',
|
||||
'string',
|
||||
Rule::enum(LabelColor::class),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,8 @@ class StoreTransactionRequest extends FormRequest
|
|||
'notes' => ['nullable', 'string'],
|
||||
'notes_iv' => ['nullable', 'string', 'size:16'],
|
||||
'source' => ['required', Rule::enum(TransactionSource::class)],
|
||||
'label_ids' => ['nullable', 'array'],
|
||||
'label_ids.*' => ['string', 'uuid', 'exists:labels,id'],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class AutomationRule extends Model
|
||||
|
|
@ -40,4 +41,10 @@ class AutomationRule extends Model
|
|||
{
|
||||
return $this->belongsTo(Category::class, 'action_category_id');
|
||||
}
|
||||
|
||||
public function labels(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Label::class, 'automation_rule_labels')
|
||||
->using(AutomationRuleLabel::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class AutomationRuleLabel extends Pivot
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'automation_rule_labels';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Label extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\LabelFactory> */
|
||||
use HasFactory, HasUuids, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'color',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function transactions(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Transaction::class)
|
||||
->using(LabelTransaction::class)
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function automationRules(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(AutomationRule::class, 'automation_rule_labels')
|
||||
->using(AutomationRuleLabel::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class LabelTransaction extends Pivot
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'label_transaction';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Transaction extends Model
|
||||
|
|
@ -51,4 +52,11 @@ class Transaction extends Model
|
|||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function labels(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Label::class)
|
||||
->using(LabelTransaction::class)
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,11 @@ class User extends Authenticatable
|
|||
return $this->hasMany(AutomationRule::class);
|
||||
}
|
||||
|
||||
public function labels(): HasMany
|
||||
{
|
||||
return $this->hasMany(Label::class);
|
||||
}
|
||||
|
||||
public function hasProPlan(): bool
|
||||
{
|
||||
if (! config('subscriptions.enabled')) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\User;
|
||||
|
||||
class LabelPolicy
|
||||
{
|
||||
/**
|
||||
* 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, Label $label): 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, Label $label): bool
|
||||
{
|
||||
return $user->id === $label->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, Label $label): bool
|
||||
{
|
||||
return $user->id === $label->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, Label $label): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, Label $label): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Label>
|
||||
*/
|
||||
class LabelFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->unique()->word(),
|
||||
'color' => fake()->randomElement(['amber', 'blue', 'cyan', 'emerald', 'gray', 'green', 'indigo', 'orange', 'pink', 'purple', 'red', 'slate', 'teal', 'yellow']),
|
||||
'user_id' => User::factory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?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('labels', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('color', 50);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->unique(['user_id', 'name']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('labels');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?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('label_transaction', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('label_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUuid('transaction_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['label_id', 'transaction_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('label_transaction');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?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_rule_labels', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('automation_rule_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUuid('label_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
$table->unique(['automation_rule_id', 'label_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('automation_rule_labels');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?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::table('labels', function (Blueprint $table) {
|
||||
$table->dropForeign(['user_id']);
|
||||
$table->dropUnique(['user_id', 'name']);
|
||||
$table->unique(['user_id', 'name', 'deleted_at']);
|
||||
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('labels', function (Blueprint $table) {
|
||||
$table->dropForeign(['user_id']);
|
||||
$table->dropUnique(['user_id', 'name', 'deleted_at']);
|
||||
$table->unique(['user_id', 'name']);
|
||||
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { store } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
|
||||
import { RuleBuilder } from '@/components/automation-rules/rule-builder';
|
||||
import { CategoryCombobox } from '@/components/shared/category-combobox';
|
||||
import { LabelCombobox } from '@/components/shared/label-combobox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -11,7 +12,7 @@ import {
|
|||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Label as FormLabel } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { encrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
|
|
@ -23,7 +24,9 @@ import {
|
|||
} from '@/lib/rule-builder-utils';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import { labelSyncService } from '@/services/label-sync';
|
||||
import type { Category } from '@/types/category';
|
||||
import type { Label } from '@/types/label';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
|
|
@ -38,6 +41,7 @@ export function CreateAutomationRuleDialog({
|
|||
}: CreateAutomationRuleDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [labels, setLabels] = useState<Label[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [priority, setPriority] = useState('10');
|
||||
const [ruleStructure, setRuleStructure] = useState<RuleStructure>({
|
||||
|
|
@ -45,16 +49,21 @@ export function CreateAutomationRuleDialog({
|
|||
groupOperator: 'or',
|
||||
});
|
||||
const [categoryId, setCategoryId] = useState<string>('');
|
||||
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
|
||||
const [note, setNote] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
const data = await categorySyncService.getAll();
|
||||
setCategories(data);
|
||||
const loadData = async () => {
|
||||
const [categoriesData, labelsData] = await Promise.all([
|
||||
categorySyncService.getAll(),
|
||||
labelSyncService.getAll(),
|
||||
]);
|
||||
setCategories(categoriesData);
|
||||
setLabels(labelsData);
|
||||
};
|
||||
loadCategories();
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
|
|
@ -74,7 +83,7 @@ export function CreateAutomationRuleDialog({
|
|||
return;
|
||||
}
|
||||
|
||||
if (!categoryId && !note.trim()) {
|
||||
if (!categoryId && !note.trim() && selectedLabelIds.length === 0) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
action_category_id: 'At least one action is required',
|
||||
|
|
@ -110,6 +119,8 @@ export function CreateAutomationRuleDialog({
|
|||
action_category_id: categoryId || null,
|
||||
action_note: encryptedNote,
|
||||
action_note_iv: noteIv,
|
||||
action_label_ids:
|
||||
selectedLabelIds.length > 0 ? selectedLabelIds : null,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
|
|
@ -123,6 +134,7 @@ export function CreateAutomationRuleDialog({
|
|||
groupOperator: 'and',
|
||||
});
|
||||
setCategoryId('');
|
||||
setSelectedLabelIds([]);
|
||||
setNote('');
|
||||
setErrors({});
|
||||
await automationRuleSyncService.sync();
|
||||
|
|
@ -157,7 +169,7 @@ export function CreateAutomationRuleDialog({
|
|||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
|
|
@ -173,7 +185,7 @@ export function CreateAutomationRuleDialog({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="priority">Priority</Label>
|
||||
<FormLabel htmlFor="priority">Priority</FormLabel>
|
||||
<Input
|
||||
id="priority"
|
||||
type="number"
|
||||
|
|
@ -206,7 +218,9 @@ export function CreateAutomationRuleDialog({
|
|||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Set Category</Label>
|
||||
<FormLabel htmlFor="category">
|
||||
Set Category
|
||||
</FormLabel>
|
||||
<CategoryCombobox
|
||||
value={categoryId}
|
||||
onValueChange={setCategoryId}
|
||||
|
|
@ -217,7 +231,19 @@ export function CreateAutomationRuleDialog({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="note">Add Note</Label>
|
||||
<FormLabel>Add Labels</FormLabel>
|
||||
<LabelCombobox
|
||||
value={selectedLabelIds}
|
||||
onValueChange={setSelectedLabelIds}
|
||||
labels={labels}
|
||||
onLabelsChange={setLabels}
|
||||
placeholder="Select labels (optional)"
|
||||
allowCreate={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="note">Add Note</FormLabel>
|
||||
<Textarea
|
||||
id="note"
|
||||
value={note}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
import { store } from '@/actions/App/Http/Controllers/Settings/LabelController';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { labelSyncService } from '@/services/label-sync';
|
||||
import { getLabelColorClasses, LABEL_COLORS } from '@/types/label';
|
||||
import { Form } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function CreateLabelDialog({ onSuccess }: { onSuccess?: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Create Label</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Label</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new label to tag your transactions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form
|
||||
{...store.form()}
|
||||
onSuccess={async () => {
|
||||
await labelSyncService.sync();
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Label name"
|
||||
required
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">Color</Label>
|
||||
<Select name="color" required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a color" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LABEL_COLORS.map((color) => {
|
||||
const colorClasses =
|
||||
getLabelColorClasses(color);
|
||||
return (
|
||||
<SelectItem
|
||||
key={color}
|
||||
value={color}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
className={`${colorClasses.bg} ${colorClasses.text}`}
|
||||
>
|
||||
{color}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.color && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.color}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={processing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { destroy } from '@/actions/App/Http/Controllers/Settings/LabelController';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { labelSyncService } from '@/services/label-sync';
|
||||
import { type Label } from '@/types/label';
|
||||
import { Form } from '@inertiajs/react';
|
||||
|
||||
interface DeleteLabelDialogProps {
|
||||
label: Label;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function DeleteLabelDialog({
|
||||
label,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: DeleteLabelDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Label</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{label.name}"? This
|
||||
action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form
|
||||
{...destroy.form.delete(label.id)}
|
||||
onSuccess={async () => {
|
||||
await labelSyncService.delete(label.id);
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
}}
|
||||
>
|
||||
{({ processing }) => (
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={processing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { update } from '@/actions/App/Http/Controllers/Settings/LabelController';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { labelSyncService } from '@/services/label-sync';
|
||||
import {
|
||||
getLabelColorClasses,
|
||||
LABEL_COLORS,
|
||||
type Label as LabelType,
|
||||
} from '@/types/label';
|
||||
import { Form } from '@inertiajs/react';
|
||||
|
||||
interface EditLabelDialogProps {
|
||||
label: LabelType;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function EditLabelDialog({
|
||||
label,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: EditLabelDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Label</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the label information.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form
|
||||
{...update.form.patch(label.id)}
|
||||
onSuccess={async () => {
|
||||
await labelSyncService.sync();
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
defaultValue={label.name}
|
||||
placeholder="Label name"
|
||||
required
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">Color</Label>
|
||||
<Select
|
||||
name="color"
|
||||
defaultValue={label.color}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a color" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LABEL_COLORS.map((color) => {
|
||||
const colorClasses =
|
||||
getLabelColorClasses(color);
|
||||
return (
|
||||
<SelectItem
|
||||
key={color}
|
||||
value={color}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
className={`${colorClasses.bg} ${colorClasses.text}`}
|
||||
>
|
||||
{color}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.color && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.color}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={processing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -300,6 +300,7 @@ export function StepCreateAccount({
|
|||
|
||||
<StepButton
|
||||
type="submit"
|
||||
className="w-full sm:w-full"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
loadingText="Creating..."
|
||||
|
|
|
|||
|
|
@ -0,0 +1,282 @@
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { labelSyncService } from '@/services/label-sync';
|
||||
import { getLabelColorClasses, LABEL_COLORS, type Label } from '@/types/label';
|
||||
import { Check, ChevronsUpDown, Plus, Tag, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface LabelComboboxProps {
|
||||
value: string[];
|
||||
onValueChange: (value: string[]) => void;
|
||||
labels: Label[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
triggerClassName?: string;
|
||||
allowCreate?: boolean;
|
||||
}
|
||||
|
||||
export function LabelCombobox({
|
||||
value,
|
||||
onValueChange,
|
||||
labels,
|
||||
disabled = false,
|
||||
placeholder = 'Add labels...',
|
||||
triggerClassName,
|
||||
allowCreate = true,
|
||||
}: LabelComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const selectedLabels = labels.filter((l) => value.includes(l.id));
|
||||
|
||||
const sortedLabels = [...labels].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
const handleSelect = (labelId: string) => {
|
||||
if (value.includes(labelId)) {
|
||||
onValueChange(value.filter((id) => id !== labelId));
|
||||
} else {
|
||||
onValueChange([...value, labelId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (labelId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onValueChange(value.filter((id) => id !== labelId));
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!inputValue.trim() || isCreating) return;
|
||||
|
||||
const existingLabel = labels.find(
|
||||
(l) => l.name.toLowerCase() === inputValue.toLowerCase(),
|
||||
);
|
||||
if (existingLabel) {
|
||||
handleSelect(existingLabel.id);
|
||||
setInputValue('');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const randomColor =
|
||||
LABEL_COLORS[Math.floor(Math.random() * LABEL_COLORS.length)];
|
||||
const newLabel = await labelSyncService.findOrCreate(
|
||||
inputValue.trim(),
|
||||
randomColor,
|
||||
);
|
||||
|
||||
if (newLabel) {
|
||||
onValueChange([...value, newLabel.id]);
|
||||
setInputValue('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create label:', error);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showCreateOption =
|
||||
allowCreate &&
|
||||
inputValue.trim() &&
|
||||
!labels.some((l) => l.name.toLowerCase() === inputValue.toLowerCase());
|
||||
|
||||
return (
|
||||
<Popover modal open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
'min-h-10 w-full justify-between',
|
||||
triggerClassName,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectedLabels.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedLabels.map((label) => {
|
||||
const colorClasses = getLabelColorClasses(
|
||||
label.color,
|
||||
);
|
||||
return (
|
||||
<Badge
|
||||
key={label.id}
|
||||
className={cn(
|
||||
'gap-1 px-2 py-0.5',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
)}
|
||||
>
|
||||
<Tag className="h-3 w-3" />
|
||||
{label.name}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) =>
|
||||
handleRemove(label.id, e)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === 'Enter' ||
|
||||
e.key === ' '
|
||||
) {
|
||||
handleRemove(
|
||||
label.id,
|
||||
e as unknown as React.MouseEvent,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="ml-0.5 cursor-pointer rounded-full hover:bg-black/10"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search or create labels..."
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
/>
|
||||
<CommandList>
|
||||
{sortedLabels.length === 0 && !showCreateOption && (
|
||||
<CommandEmpty>No labels found.</CommandEmpty>
|
||||
)}
|
||||
{showCreateOption && (
|
||||
<CommandItem
|
||||
onSelect={handleCreate}
|
||||
disabled={isCreating}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{isCreating
|
||||
? 'Creating...'
|
||||
: `Create "${inputValue.trim()}"`}
|
||||
</CommandItem>
|
||||
)}
|
||||
{sortedLabels
|
||||
.filter(
|
||||
(label) =>
|
||||
!inputValue ||
|
||||
label.name
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase()),
|
||||
)
|
||||
.map((label) => {
|
||||
const colorClasses = getLabelColorClasses(
|
||||
label.color,
|
||||
);
|
||||
const isSelected = value.includes(label.id);
|
||||
return (
|
||||
<CommandItem
|
||||
key={label.id}
|
||||
value={label.name}
|
||||
onSelect={() => handleSelect(label.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 items-center justify-center rounded-full',
|
||||
colorClasses.bg,
|
||||
)}
|
||||
>
|
||||
<Tag
|
||||
className={cn(
|
||||
'h-3 w-3',
|
||||
colorClasses.text,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate">
|
||||
{label.name}
|
||||
</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto h-4 w-4',
|
||||
isSelected
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function LabelBadge({ label }: { label: Label }) {
|
||||
const colorClasses = getLabelColorClasses(label.color);
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
'gap-1 px-2 py-0.5',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
)}
|
||||
>
|
||||
<Tag className="h-3 w-3" />
|
||||
{label.name}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function LabelBadges({
|
||||
labels,
|
||||
max = 3,
|
||||
}: {
|
||||
labels: Label[];
|
||||
max?: number;
|
||||
}) {
|
||||
if (!labels || labels.length === 0) return null;
|
||||
|
||||
const displayLabels = labels.slice(0, max);
|
||||
const remainingCount = labels.length - max;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{displayLabels.map((label) => (
|
||||
<LabelBadge key={label.id} label={label} />
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<Badge variant="outline" className="px-2 py-0.5">
|
||||
+{remainingCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { BulkCategorySelect } from '@/components/transactions/bulk-category-select';
|
||||
import { BulkLabelSelect } from '@/components/transactions/bulk-label-select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonGroup } from '@/components/ui/button-group';
|
||||
import {
|
||||
|
|
@ -9,12 +10,15 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Label } from '@/types/label';
|
||||
import { MoreHorizontal, Trash2, WandSparkles, X } from 'lucide-react';
|
||||
|
||||
interface BulkActionsBarProps {
|
||||
selectedCount: number;
|
||||
categories: Category[];
|
||||
labels: Label[];
|
||||
onCategoryChange: (categoryId: number | null) => void;
|
||||
onLabelsChange: (labelIds: string[]) => void;
|
||||
onDelete: () => void;
|
||||
onReEvaluateRules: () => void;
|
||||
onClear: () => void;
|
||||
|
|
@ -24,7 +28,9 @@ interface BulkActionsBarProps {
|
|||
export function BulkActionsBar({
|
||||
selectedCount,
|
||||
categories,
|
||||
labels,
|
||||
onCategoryChange,
|
||||
onLabelsChange,
|
||||
onDelete,
|
||||
onReEvaluateRules,
|
||||
onClear,
|
||||
|
|
@ -50,6 +56,12 @@ export function BulkActionsBar({
|
|||
disabled={isUpdating}
|
||||
/>
|
||||
|
||||
<BulkLabelSelect
|
||||
labels={labels}
|
||||
onLabelsChange={onLabelsChange}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { LabelCombobox } from '@/components/shared/label-combobox';
|
||||
import { type Label } from '@/types/label';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface BulkLabelSelectProps {
|
||||
labels: Label[];
|
||||
onLabelsChange: (labelIds: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function BulkLabelSelect({
|
||||
labels,
|
||||
onLabelsChange,
|
||||
disabled = false,
|
||||
}: BulkLabelSelectProps) {
|
||||
const [value, setValue] = useState<string[]>([]);
|
||||
|
||||
function handleChange(newValue: string[]) {
|
||||
setValue(newValue);
|
||||
onLabelsChange(newValue);
|
||||
}
|
||||
|
||||
return (
|
||||
<LabelCombobox
|
||||
value={value}
|
||||
onValueChange={handleChange}
|
||||
labels={labels}
|
||||
disabled={disabled}
|
||||
placeholder="Add labels"
|
||||
triggerClassName="h-9 w-[180px] min-h-9"
|
||||
allowCreate={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { LabelCombobox } from '@/components/shared/label-combobox';
|
||||
import { CategorySelect } from '@/components/transactions/category-select';
|
||||
import { AmountInput } from '@/components/ui/amount-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -10,7 +11,7 @@ import {
|
|||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Label as FormLabel } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -33,6 +34,7 @@ import {
|
|||
} from '@/types/account';
|
||||
import { type AutomationRule } from '@/types/automation-rule';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Label } from '@/types/label';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import { format, getYear, parseISO } from 'date-fns';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
|
@ -43,6 +45,7 @@ interface EditTransactionDialogProps {
|
|||
categories: Category[];
|
||||
accounts: Account[];
|
||||
banks: Bank[];
|
||||
labels: Label[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: (transaction: DecryptedTransaction) => void;
|
||||
|
|
@ -54,6 +57,7 @@ export function EditTransactionDialog({
|
|||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
labels,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
|
|
@ -65,6 +69,7 @@ export function EditTransactionDialog({
|
|||
const [amount, setAmount] = useState<number>(0);
|
||||
const [accountId, setAccountId] = useState<string>('');
|
||||
const [categoryId, setCategoryId] = useState<string>('null');
|
||||
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [decryptedAccountNames, setDecryptedAccountNames] = useState<
|
||||
|
|
@ -81,6 +86,11 @@ export function EditTransactionDialog({
|
|||
setAmount(transaction.amount);
|
||||
setAccountId(transaction.account_id);
|
||||
setCategoryId(transaction.category_id || 'null');
|
||||
setSelectedLabelIds(
|
||||
transaction.label_ids ||
|
||||
transaction.labels?.map((l) => l.id) ||
|
||||
[],
|
||||
);
|
||||
setNotes(transaction.decryptedNotes || '');
|
||||
} else if (mode === 'create' && open) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
|
@ -92,6 +102,7 @@ export function EditTransactionDialog({
|
|||
availableAccounts.length > 0 ? availableAccounts[0].id : '',
|
||||
);
|
||||
setCategoryId('null');
|
||||
setSelectedLabelIds([]);
|
||||
setNotes('');
|
||||
}
|
||||
}, [mode, transaction, open, accounts]);
|
||||
|
|
@ -366,10 +377,12 @@ export function EditTransactionDialog({
|
|||
notes_iv: string | null;
|
||||
description?: string;
|
||||
description_iv?: string;
|
||||
label_ids?: string[];
|
||||
} = {
|
||||
category_id: selectedCategoryId,
|
||||
notes: encryptedNotes,
|
||||
notes_iv: notesIv,
|
||||
label_ids: selectedLabelIds,
|
||||
};
|
||||
|
||||
let finalDecryptedDescription =
|
||||
|
|
@ -399,6 +412,10 @@ export function EditTransactionDialog({
|
|||
) || null
|
||||
: null;
|
||||
|
||||
const selectedLabels = labels.filter((label) =>
|
||||
selectedLabelIds.includes(label.id),
|
||||
);
|
||||
|
||||
const updatedTransaction: DecryptedTransaction = {
|
||||
...transaction,
|
||||
category_id: selectedCategoryId,
|
||||
|
|
@ -411,6 +428,8 @@ export function EditTransactionDialog({
|
|||
decryptedNotes: trimmedNotes || null,
|
||||
notes: encryptedNotes,
|
||||
notes_iv: notesIv,
|
||||
label_ids: selectedLabelIds,
|
||||
labels: selectedLabels,
|
||||
updated_at:
|
||||
updatedRecord?.updated_at ?? transaction.updated_at,
|
||||
};
|
||||
|
|
@ -451,7 +470,7 @@ export function EditTransactionDialog({
|
|||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
<FormLabel
|
||||
htmlFor="date"
|
||||
className={
|
||||
mode === 'edit'
|
||||
|
|
@ -460,7 +479,7 @@ export function EditTransactionDialog({
|
|||
}
|
||||
>
|
||||
Date
|
||||
</Label>
|
||||
</FormLabel>
|
||||
{mode === 'create' ? (
|
||||
<Input
|
||||
id="date"
|
||||
|
|
@ -495,7 +514,7 @@ export function EditTransactionDialog({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
<FormLabel
|
||||
htmlFor="description"
|
||||
className={
|
||||
mode === 'edit' &&
|
||||
|
|
@ -505,7 +524,7 @@ export function EditTransactionDialog({
|
|||
}
|
||||
>
|
||||
Description
|
||||
</Label>
|
||||
</FormLabel>
|
||||
{mode === 'create' ||
|
||||
(mode === 'edit' &&
|
||||
transaction?.source === 'manually_created') ? (
|
||||
|
|
@ -542,7 +561,7 @@ export function EditTransactionDialog({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
<FormLabel
|
||||
htmlFor="amount"
|
||||
className={
|
||||
mode === 'edit'
|
||||
|
|
@ -551,7 +570,7 @@ export function EditTransactionDialog({
|
|||
}
|
||||
>
|
||||
Amount
|
||||
</Label>
|
||||
</FormLabel>
|
||||
{mode === 'create' ? (
|
||||
<AmountInput
|
||||
id="amount"
|
||||
|
|
@ -576,7 +595,7 @@ export function EditTransactionDialog({
|
|||
|
||||
{mode === 'create' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account">Account</Label>
|
||||
<FormLabel htmlFor="account">Account</FormLabel>
|
||||
<Select
|
||||
value={accountId}
|
||||
onValueChange={setAccountId}
|
||||
|
|
@ -604,7 +623,7 @@ export function EditTransactionDialog({
|
|||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category</Label>
|
||||
<FormLabel htmlFor="category">Category</FormLabel>
|
||||
<CategorySelect
|
||||
value={categoryId}
|
||||
onValueChange={setCategoryId}
|
||||
|
|
@ -617,7 +636,19 @@ export function EditTransactionDialog({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<FormLabel>Labels</FormLabel>
|
||||
<LabelCombobox
|
||||
value={selectedLabelIds}
|
||||
onValueChange={setSelectedLabelIds}
|
||||
labels={labels}
|
||||
disabled={isSubmitting}
|
||||
placeholder="Add labels..."
|
||||
allowCreate={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="notes">Notes</FormLabel>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder="Add notes..."
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { format, getYear, parseISO } from 'date-fns';
|
|||
import { ArrowDown, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
import { EncryptedText } from '@/components/encrypted-text';
|
||||
import { LabelBadges } from '@/components/shared/label-combobox';
|
||||
import { CategoryCell } from '@/components/transactions/category-cell';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
|
@ -15,12 +16,14 @@ import {
|
|||
} from '@/components/ui/dropdown-menu';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Label } from '@/types/label';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
|
||||
interface CreateColumnsOptions {
|
||||
categories: Category[];
|
||||
accounts: Account[];
|
||||
banks: Bank[];
|
||||
labels: Label[];
|
||||
onEdit: (transaction: DecryptedTransaction) => void;
|
||||
onDelete: (transaction: DecryptedTransaction) => void;
|
||||
onUpdate: (transaction: DecryptedTransaction) => void;
|
||||
|
|
@ -31,6 +34,7 @@ export function createTransactionColumns({
|
|||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
labels,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
|
|
@ -128,6 +132,24 @@ export function createTransactionColumns({
|
|||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'labels',
|
||||
accessorKey: 'label_ids',
|
||||
meta: { label: 'Labels' },
|
||||
header: 'Labels',
|
||||
cell: ({ row }) => {
|
||||
const transaction = row.original;
|
||||
// Resolve labels from label_ids using the labels map
|
||||
const transactionLabels = (transaction.label_ids || [])
|
||||
.map((id) => labels.find((l) => l.id === id))
|
||||
.filter(Boolean) as Label[];
|
||||
if (transactionLabels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return <LabelBadges labels={transactionLabels} max={2} />;
|
||||
},
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'bank',
|
||||
meta: { label: 'Bank' },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { format } from 'date-fns';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { Check, ChevronsUpDown, X } from 'lucide-react';
|
||||
import { Check, ChevronsUpDown, Tag, X } from 'lucide-react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { EncryptedText } from '@/components/encrypted-text';
|
||||
|
|
@ -14,7 +14,7 @@ import {
|
|||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Label as FormLabel } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import { cn } from '@/lib/utils';
|
||||
import { type Account } from '@/types/account';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
import { getLabelColorClasses, type Label } from '@/types/label';
|
||||
import { type TransactionFilters as FiltersType } from '@/types/transaction';
|
||||
import { CategoryIcon } from '../shared/category-combobox';
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ interface TransactionFiltersProps {
|
|||
filters: FiltersType;
|
||||
onFiltersChange: (filters: FiltersType) => void;
|
||||
categories: Category[];
|
||||
labels: Label[];
|
||||
accounts: Account[];
|
||||
isKeySet: boolean;
|
||||
actions?: ReactNode;
|
||||
|
|
@ -40,6 +42,7 @@ export function TransactionFilters({
|
|||
filters,
|
||||
onFiltersChange,
|
||||
categories,
|
||||
labels,
|
||||
accounts,
|
||||
isKeySet,
|
||||
actions,
|
||||
|
|
@ -47,6 +50,7 @@ export function TransactionFilters({
|
|||
}: TransactionFiltersProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
|
||||
const [labelDropdownOpen, setLabelDropdownOpen] = useState(false);
|
||||
const isUncategorizedSelected = filters.categoryIds.includes(
|
||||
UNCATEGORIZED_CATEGORY_ID,
|
||||
);
|
||||
|
|
@ -67,6 +71,14 @@ export function TransactionFilters({
|
|||
onFiltersChange({ ...filters, accountIds: newAccountIds });
|
||||
}
|
||||
|
||||
function handleLabelToggle(labelId: string) {
|
||||
const newLabelIds = filters.labelIds.includes(labelId)
|
||||
? filters.labelIds.filter((id) => id !== labelId)
|
||||
: [...filters.labelIds, labelId];
|
||||
|
||||
onFiltersChange({ ...filters, labelIds: newLabelIds });
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
onFiltersChange({
|
||||
dateFrom: null,
|
||||
|
|
@ -75,6 +87,7 @@ export function TransactionFilters({
|
|||
amountMax: null,
|
||||
categoryIds: [],
|
||||
accountIds: [],
|
||||
labelIds: [],
|
||||
searchText: '',
|
||||
});
|
||||
}
|
||||
|
|
@ -85,6 +98,7 @@ export function TransactionFilters({
|
|||
(filters.amountMin !== null ? 1 : 0) +
|
||||
(filters.amountMax !== null ? 1 : 0) +
|
||||
filters.categoryIds.length +
|
||||
filters.labelIds.length +
|
||||
(hideAccountFilter ? 0 : filters.accountIds.length);
|
||||
|
||||
return (
|
||||
|
|
@ -132,7 +146,7 @@ export function TransactionFilters({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Date</Label>
|
||||
<FormLabel>Date</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<Input
|
||||
type="date"
|
||||
|
|
@ -182,7 +196,7 @@ export function TransactionFilters({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Amount</Label>
|
||||
<FormLabel>Amount</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<Input
|
||||
type="number"
|
||||
|
|
@ -220,7 +234,7 @@ export function TransactionFilters({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Categories</Label>
|
||||
<FormLabel>Categories</FormLabel>
|
||||
<div className="pt-2">
|
||||
<Popover
|
||||
open={categoryDropdownOpen}
|
||||
|
|
@ -350,9 +364,109 @@ export function TransactionFilters({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Labels</FormLabel>
|
||||
<div className="pt-2">
|
||||
<Popover
|
||||
open={labelDropdownOpen}
|
||||
onOpenChange={setLabelDropdownOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{filters.labelIds.length >
|
||||
0 ? (
|
||||
<span className="truncate">
|
||||
{
|
||||
filters.labelIds
|
||||
.length
|
||||
}{' '}
|
||||
selected
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
Select labels...
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search labels..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No labels found.
|
||||
</CommandEmpty>
|
||||
{labels.map((label) => {
|
||||
const isSelected =
|
||||
filters.labelIds.includes(
|
||||
label.id,
|
||||
);
|
||||
const colorClasses =
|
||||
getLabelColorClasses(
|
||||
label.color,
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={
|
||||
label.id
|
||||
}
|
||||
onSelect={() =>
|
||||
handleLabelToggle(
|
||||
label.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
|
||||
isSelected
|
||||
? 'bg-primary/10 text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible',
|
||||
)}
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 items-center justify-center rounded-full',
|
||||
colorClasses.bg,
|
||||
)}
|
||||
>
|
||||
<Tag
|
||||
className={cn(
|
||||
'h-3 w-3',
|
||||
colorClasses.text,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
{
|
||||
label.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hideAccountFilter && (
|
||||
<div className="space-y-2">
|
||||
<Label>Accounts</Label>
|
||||
<FormLabel>Accounts</FormLabel>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{accounts.map((account) => {
|
||||
const isSelected =
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { accountSyncService } from '@/services/account-sync';
|
|||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { bankSyncService } from '@/services/bank-sync';
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import { labelSyncService } from '@/services/label-sync';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import type { User } from '@/types/index.d';
|
||||
import type { Page } from '@inertiajs/core';
|
||||
|
|
@ -149,6 +150,7 @@ export function SyncProvider({
|
|||
accountBalancesResult,
|
||||
banksResult,
|
||||
automationRulesResult,
|
||||
labelsResult,
|
||||
transactionsResult,
|
||||
] = await Promise.all([
|
||||
categorySyncService.sync(),
|
||||
|
|
@ -156,6 +158,7 @@ export function SyncProvider({
|
|||
accountBalanceSyncService.sync(),
|
||||
bankSyncService.sync(),
|
||||
automationRuleSyncService.sync(),
|
||||
labelSyncService.sync(),
|
||||
transactionSyncService.sync(),
|
||||
]);
|
||||
|
||||
|
|
@ -165,6 +168,7 @@ export function SyncProvider({
|
|||
...accountBalancesResult.errors,
|
||||
...banksResult.errors,
|
||||
...automationRulesResult.errors,
|
||||
...labelsResult.errors,
|
||||
...transactionsResult.errors,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController';
|
||||
import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
|
||||
import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController';
|
||||
import { index as labelsIndex } from '@/actions/App/Http/Controllers/Settings/LabelController';
|
||||
import Heading from '@/components/heading';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
|
@ -39,6 +40,12 @@ const getNavItems = (
|
|||
href: categoriesIndex(),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
type: 'nav-item',
|
||||
title: 'Labels',
|
||||
href: labelsIndex(),
|
||||
icon: null,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'section-header',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Transaction } from '@/services/transaction-sync';
|
|||
import type { Account, AccountBalance, Bank } from '@/types/account';
|
||||
import type { AutomationRule } from '@/types/automation-rule';
|
||||
import type { Category } from '@/types/category';
|
||||
import type { Label } from '@/types/label';
|
||||
import Dexie, { type EntityTable } from 'dexie';
|
||||
|
||||
export interface SyncMetadata {
|
||||
|
|
@ -21,6 +22,7 @@ const db = new Dexie('whisper_money') as Dexie & {
|
|||
transactions: EntityTable<Transaction, 'id'>;
|
||||
accounts: EntityTable<Account, 'id'>;
|
||||
categories: EntityTable<Category, 'id'>;
|
||||
labels: EntityTable<Label, 'id'>;
|
||||
banks: EntityTable<Bank, 'id'>;
|
||||
automation_rules: EntityTable<AutomationRule, 'id'>;
|
||||
account_balances: EntityTable<AccountBalance, 'id'>;
|
||||
|
|
@ -39,4 +41,16 @@ db.version(5).stores({
|
|||
pending_changes: '++id, store, timestamp',
|
||||
});
|
||||
|
||||
db.version(6).stores({
|
||||
transactions: 'id, user_id, account_id, updated_at',
|
||||
accounts: 'id, user_id, bank_id, updated_at',
|
||||
categories: 'id, user_id, updated_at',
|
||||
labels: 'id, user_id, updated_at',
|
||||
banks: 'id, user_id, updated_at',
|
||||
automation_rules: 'id, user_id, priority, updated_at',
|
||||
account_balances: 'id, account_id, balance_date, updated_at',
|
||||
sync_metadata: 'key',
|
||||
pending_changes: '++id, store, timestamp',
|
||||
});
|
||||
|
||||
export { db };
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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 { Label } from '@/types/label';
|
||||
import type { DecryptedTransaction } from '@/types/transaction';
|
||||
import type { UUID } from '@/types/uuid';
|
||||
import jsonLogic from 'json-logic-js';
|
||||
|
|
@ -10,6 +11,8 @@ import jsonLogic from 'json-logic-js';
|
|||
export interface RuleEvaluationResult {
|
||||
rule: AutomationRule;
|
||||
categoryId: UUID | null;
|
||||
labelIds: UUID[];
|
||||
labels: Label[];
|
||||
note: string | null;
|
||||
noteIv: string | null;
|
||||
}
|
||||
|
|
@ -157,6 +160,8 @@ export async function evaluateRules(
|
|||
return {
|
||||
rule,
|
||||
categoryId: rule.action_category_id,
|
||||
labelIds: rule.labels?.map((l) => l.id) || [],
|
||||
labels: rule.labels || [],
|
||||
note: rule.action_note,
|
||||
noteIv: rule.action_note_iv,
|
||||
};
|
||||
|
|
@ -288,6 +293,8 @@ export async function evaluateRulesForNewTransaction(
|
|||
return {
|
||||
rule,
|
||||
categoryId: rule.action_category_id,
|
||||
labelIds: rule.labels?.map((l) => l.id) || [],
|
||||
labels: rule.labels || [],
|
||||
note: rule.action_note,
|
||||
noteIv: rule.action_note_iv,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,335 @@
|
|||
import { Head } from '@inertiajs/react';
|
||||
import {
|
||||
Cell,
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
Row,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { ArrowUpDown, MoreHorizontal, Tag } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { index as labelsIndex } from '@/actions/App/Http/Controllers/Settings/LabelController';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import { CreateLabelDialog } from '@/components/labels/create-label-dialog';
|
||||
import { DeleteLabelDialog } from '@/components/labels/delete-label-dialog';
|
||||
import { EditLabelDialog } from '@/components/labels/edit-label-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { db } from '@/lib/dexie-db';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { getLabelColorClasses, type Label } from '@/types/label';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Labels settings',
|
||||
href: labelsIndex().url,
|
||||
},
|
||||
];
|
||||
|
||||
function LabelActions({ label }: { label: Label }) {
|
||||
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>
|
||||
|
||||
<EditLabelDialog
|
||||
label={label}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
onSuccess={() => {}}
|
||||
/>
|
||||
<DeleteLabelDialog
|
||||
label={label}
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
onSuccess={() => {}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelRow({ row }: { row: Row<Label> }) {
|
||||
const label = row.original;
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu onOpenChange={setContextMenuOpen}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<TableRow
|
||||
data-state={
|
||||
(row.getIsSelected() || contextMenuOpen) &&
|
||||
'selected'
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<Label, unknown>) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuLabel>Actions</ContextMenuLabel>
|
||||
<ContextMenuItem onClick={() => setEditOpen(true)}>
|
||||
Edit
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
variant="destructive"
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<EditLabelDialog
|
||||
label={label}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
onSuccess={() => {}}
|
||||
/>
|
||||
<DeleteLabelDialog
|
||||
label={label}
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
onSuccess={() => {}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Labels() {
|
||||
const labels = useLiveQuery(() => db.labels.toArray(), []) || [];
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'name', desc: false },
|
||||
]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
{},
|
||||
);
|
||||
|
||||
const columns: ColumnDef<Label>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
Name
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3 pl-3">
|
||||
<Tag className="h-4 w-4 opacity-80" />
|
||||
<div className="font-medium">
|
||||
{row.getValue('name')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'color',
|
||||
header: 'Color',
|
||||
cell: ({ row }) => {
|
||||
const color = row.getValue('color') as Label['color'];
|
||||
if (!color) {
|
||||
return null;
|
||||
}
|
||||
const colorClasses = getLabelColorClasses(color);
|
||||
return (
|
||||
<Badge
|
||||
className={`${colorClasses.bg} ${colorClasses.text} text-[10px] tracking-widest`}
|
||||
>
|
||||
{color.toLocaleUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => <LabelActions label={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: labels,
|
||||
columns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Labels settings" />
|
||||
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
<HeadingSmall
|
||||
title="Labels settings"
|
||||
description="Manage your transaction labels"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Input
|
||||
placeholder="Filter labels..."
|
||||
value={
|
||||
(table
|
||||
.getColumn('name')
|
||||
?.getFilterValue() as string) ?? ''
|
||||
}
|
||||
onChange={(event) =>
|
||||
table
|
||||
.getColumn('name')
|
||||
?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<CreateLabelDialog onSuccess={() => {}} />
|
||||
</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) => (
|
||||
<LabelRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No labels found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{table.getFilteredRowModel().rows.length}{' '}
|
||||
label(s) total.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ 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 Label } from '@/types/label';
|
||||
import {
|
||||
type DecryptedTransaction,
|
||||
type TransactionFilters as Filters,
|
||||
|
|
@ -77,6 +78,7 @@ interface Props {
|
|||
categories: Category[];
|
||||
accounts: Account[];
|
||||
banks: Bank[];
|
||||
labels: Label[];
|
||||
}
|
||||
|
||||
const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility';
|
||||
|
|
@ -157,8 +159,9 @@ function TransactionRowComponent({
|
|||
}
|
||||
|
||||
function getInitialColumnVisibility(): VisibilityState {
|
||||
const defaultVisibility = { bank: false };
|
||||
if (typeof window === 'undefined') {
|
||||
return { account: false };
|
||||
return defaultVisibility;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -172,10 +175,15 @@ function getInitialColumnVisibility(): VisibilityState {
|
|||
error,
|
||||
);
|
||||
}
|
||||
return { account: false };
|
||||
return defaultVisibility;
|
||||
}
|
||||
|
||||
export default function Transactions({ categories, accounts, banks }: Props) {
|
||||
export default function Transactions({
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
labels: initialLabels,
|
||||
}: Props) {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
|
||||
const transactionIds = useLiveQuery(
|
||||
|
|
@ -209,8 +217,10 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
amountMax: null,
|
||||
categoryIds: [],
|
||||
accountIds: [],
|
||||
labelIds: [],
|
||||
searchText: '',
|
||||
});
|
||||
const labels = useLiveQuery(() => db.labels.toArray(), [], initialLabels);
|
||||
const [editTransaction, setEditTransaction] =
|
||||
useState<DecryptedTransaction | null>(null);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
|
|
@ -504,6 +514,17 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (filters.labelIds.length > 0) {
|
||||
const transactionLabelIds =
|
||||
transaction.labels?.map((l) => l.id) || [];
|
||||
const hasMatchingLabel = filters.labelIds.some((labelId) =>
|
||||
transactionLabelIds.includes(labelId),
|
||||
);
|
||||
if (!hasMatchingLabel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.searchText && isKeySet) {
|
||||
const searchLower = filters.searchText.toLowerCase();
|
||||
const matchesDescription = transaction.decryptedDescription
|
||||
|
|
@ -607,6 +628,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
labels,
|
||||
key,
|
||||
);
|
||||
|
||||
|
|
@ -693,7 +715,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
consoleDebug('=== Re-evaluation complete ===');
|
||||
}
|
||||
},
|
||||
[isKeySet, categories, accounts, banks, updateTransaction],
|
||||
[isKeySet, categories, accounts, banks, labels, updateTransaction],
|
||||
);
|
||||
|
||||
async function handleBulkReEvaluateRules() {
|
||||
|
|
@ -757,6 +779,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
labels,
|
||||
key,
|
||||
);
|
||||
|
||||
|
|
@ -884,12 +907,20 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
labels,
|
||||
onEdit: setEditTransaction,
|
||||
onDelete: setDeleteTransaction,
|
||||
onUpdate: updateTransaction,
|
||||
onReEvaluateRules: handleReEvaluateRules,
|
||||
}),
|
||||
[accounts, banks, categories, updateTransaction, handleReEvaluateRules],
|
||||
[
|
||||
accounts,
|
||||
banks,
|
||||
categories,
|
||||
labels,
|
||||
updateTransaction,
|
||||
handleReEvaluateRules,
|
||||
],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
|
|
@ -1041,6 +1072,52 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleBulkLabelsChange(labelIds: string[]) {
|
||||
const selectedIds = Object.keys(rowSelection);
|
||||
if (selectedIds.length === 0 || labelIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBulkUpdating(true);
|
||||
try {
|
||||
await transactionSyncService.updateMany(selectedIds, {
|
||||
label_ids: labelIds,
|
||||
});
|
||||
|
||||
const selectedLabels = labels.filter((l) =>
|
||||
labelIds.includes(l.id),
|
||||
);
|
||||
|
||||
setTransactions((previous) =>
|
||||
previous.map((transaction) => {
|
||||
if (selectedIds.includes(transaction.id.toString())) {
|
||||
const existingLabels = transaction.labels || [];
|
||||
const newLabels = [
|
||||
...existingLabels,
|
||||
...selectedLabels.filter(
|
||||
(l) =>
|
||||
!existingLabels.some(
|
||||
(el) => el.id === l.id,
|
||||
),
|
||||
),
|
||||
];
|
||||
return {
|
||||
...transaction,
|
||||
labels: newLabels,
|
||||
};
|
||||
}
|
||||
return transaction;
|
||||
}),
|
||||
);
|
||||
|
||||
setRowSelection({});
|
||||
} catch (error) {
|
||||
console.error('Failed to update transactions with labels:', error);
|
||||
} finally {
|
||||
setIsBulkUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClearSelection() {
|
||||
setRowSelection({});
|
||||
}
|
||||
|
|
@ -1081,6 +1158,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
accounts={accounts}
|
||||
isKeySet={isKeySet}
|
||||
actions={
|
||||
|
|
@ -1180,6 +1258,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
categories={categories}
|
||||
accounts={accounts}
|
||||
banks={banks}
|
||||
labels={labels}
|
||||
open={!!editTransaction}
|
||||
onOpenChange={(open) => !open && setEditTransaction(null)}
|
||||
onSuccess={updateTransaction}
|
||||
|
|
@ -1191,6 +1270,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
categories={categories}
|
||||
accounts={accounts}
|
||||
banks={banks}
|
||||
labels={labels}
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSuccess={() => {}}
|
||||
|
|
@ -1244,7 +1324,9 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
|||
<BulkActionsBar
|
||||
selectedCount={Object.keys(rowSelection).length}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
onCategoryChange={handleBulkCategoryChange}
|
||||
onLabelsChange={handleBulkLabelsChange}
|
||||
onDelete={handleBulkDeleteClick}
|
||||
onReEvaluateRules={handleBulkReEvaluateRules}
|
||||
onClear={handleClearSelection}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import { store } from '@/actions/App/Http/Controllers/Settings/LabelController';
|
||||
import { SyncManager } from '@/lib/sync-manager';
|
||||
import type { Label } from '@/types/label';
|
||||
import type { UUID } from '@/types/uuid';
|
||||
|
||||
class LabelSyncService {
|
||||
private syncManager: SyncManager;
|
||||
|
||||
constructor() {
|
||||
this.syncManager = new SyncManager({
|
||||
storeName: 'labels',
|
||||
endpoint: '/api/sync/labels',
|
||||
});
|
||||
}
|
||||
|
||||
async sync() {
|
||||
return await this.syncManager.sync();
|
||||
}
|
||||
|
||||
async getAll(): Promise<Label[]> {
|
||||
return await this.syncManager.getAll<Label>();
|
||||
}
|
||||
|
||||
async getById(id: UUID): Promise<Label | null> {
|
||||
return await this.syncManager.getById<Label>(id);
|
||||
}
|
||||
|
||||
async create(data: { name: string; color: string }): Promise<Label> {
|
||||
const csrfToken = decodeURIComponent(
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('XSRF-TOKEN='))
|
||||
?.split('=')[1] || '',
|
||||
);
|
||||
|
||||
const response = await fetch(store().url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
console.error('Failed to create label:', errorData);
|
||||
throw new Error(errorData?.message || 'Failed to create label');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const label = result.data as Label;
|
||||
|
||||
// Store the label in IndexedDB
|
||||
const { db } = await import('@/lib/dexie-db');
|
||||
await db.labels.put(label);
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
async update(id: UUID, data: Partial<Label>): Promise<void> {
|
||||
await this.syncManager.updateLocal<Label>(id, data);
|
||||
}
|
||||
|
||||
async delete(id: UUID): Promise<void> {
|
||||
await this.syncManager.deleteLocal(id);
|
||||
}
|
||||
|
||||
async getLastSyncTime(): Promise<string | null> {
|
||||
return await this.syncManager.getLastSyncTime();
|
||||
}
|
||||
|
||||
isSyncing(): boolean {
|
||||
return this.syncManager.isSyncing();
|
||||
}
|
||||
|
||||
async findOrCreate(
|
||||
name: string,
|
||||
color: string = 'blue',
|
||||
): Promise<Label | null> {
|
||||
const labels = await this.getAll();
|
||||
const existing = labels.find(
|
||||
(l) => l.name.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.create({ name, color });
|
||||
} catch (error) {
|
||||
console.error('Failed to create label:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const labelSyncService = new LabelSyncService();
|
||||
|
|
@ -17,10 +17,15 @@ export interface Transaction {
|
|||
currency_code: string;
|
||||
notes: string | null;
|
||||
notes_iv: string | null;
|
||||
label_ids?: UUID[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface TransactionUpdateData extends Partial<Transaction> {
|
||||
label_ids?: string[];
|
||||
}
|
||||
|
||||
class TransactionSyncService {
|
||||
private syncManager: SyncManager;
|
||||
|
||||
|
|
@ -28,10 +33,20 @@ class TransactionSyncService {
|
|||
this.syncManager = new SyncManager({
|
||||
storeName: 'transactions',
|
||||
endpoint: '/api/sync/transactions',
|
||||
transformFromServer: (data) => ({
|
||||
...data,
|
||||
transaction_date: String(data.transaction_date).slice(0, 10),
|
||||
}),
|
||||
transformFromServer: (data) => {
|
||||
// Extract label_ids from labels array if present
|
||||
const label_ids = data.labels?.map((l: { id: string }) => l.id);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { labels, ...rest } = data;
|
||||
return {
|
||||
...rest,
|
||||
transaction_date: String(data.transaction_date).slice(
|
||||
0,
|
||||
10,
|
||||
),
|
||||
label_ids: label_ids || [],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -104,17 +119,77 @@ class TransactionSyncService {
|
|||
}
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<Transaction>): Promise<void> {
|
||||
async update(
|
||||
id: string,
|
||||
data: TransactionUpdateData,
|
||||
): Promise<Transaction | void> {
|
||||
const existing = await this.getById(id);
|
||||
|
||||
if (!existing) {
|
||||
throw new Error('Transaction not found');
|
||||
}
|
||||
|
||||
const { label_ids, ...transactionData } = data;
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// If label_ids are provided, we need to sync with the server immediately
|
||||
if (label_ids !== undefined) {
|
||||
const csrfToken = decodeURIComponent(
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('XSRF-TOKEN='))
|
||||
?.split('=')[1] || '',
|
||||
);
|
||||
|
||||
const response = await fetch(`/api/sync/transactions/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
...existing,
|
||||
...transactionData,
|
||||
label_ids,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update transaction');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const serverData = result.data;
|
||||
|
||||
// Extract label_ids from labels array
|
||||
const serverLabelIds = serverData.labels?.map(
|
||||
(l: { id: string }) => l.id,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { labels: _labels, ...restServerData } = serverData;
|
||||
|
||||
const updatedTransaction: Transaction = {
|
||||
...restServerData,
|
||||
transaction_date: String(serverData.transaction_date).slice(
|
||||
0,
|
||||
10,
|
||||
),
|
||||
label_ids: serverLabelIds || [],
|
||||
};
|
||||
|
||||
// Update local storage with transformed data (label_ids instead of labels)
|
||||
await db.transactions.put(updatedTransaction);
|
||||
|
||||
return updatedTransaction;
|
||||
}
|
||||
|
||||
// No label_ids, use the normal offline-first approach
|
||||
const updated = {
|
||||
...existing,
|
||||
...data,
|
||||
...transactionData,
|
||||
updated_at: timestamp,
|
||||
};
|
||||
|
||||
|
|
@ -127,8 +202,38 @@ class TransactionSyncService {
|
|||
});
|
||||
}
|
||||
|
||||
async updateMany(ids: string[], data: Partial<Transaction>): Promise<void> {
|
||||
async updateMany(
|
||||
ids: string[],
|
||||
data: TransactionUpdateData,
|
||||
): Promise<void> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const { label_ids, ...transactionData } = data;
|
||||
|
||||
if (label_ids !== undefined) {
|
||||
try {
|
||||
const response = await fetch('/transactions/bulk', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
transaction_ids: ids,
|
||||
label_ids: label_ids,
|
||||
...transactionData,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to bulk update transactions');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update transactions via API:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
const existing = await this.getById(id);
|
||||
|
|
@ -140,7 +245,7 @@ class TransactionSyncService {
|
|||
|
||||
const updated = {
|
||||
...existing,
|
||||
...data,
|
||||
...transactionData,
|
||||
updated_at: timestamp,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Category } from './category';
|
||||
import type { Label } from './label';
|
||||
import { UUID } from './uuid';
|
||||
|
||||
export interface AutomationRule {
|
||||
|
|
@ -11,26 +12,35 @@ export interface AutomationRule {
|
|||
action_note: string | null;
|
||||
action_note_iv: string | null;
|
||||
category?: Category;
|
||||
labels?: Label[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
export interface RuleAction {
|
||||
type: 'category' | 'note' | 'both';
|
||||
type: 'category' | 'note' | 'labels' | 'multiple';
|
||||
category?: Category;
|
||||
labels?: Label[];
|
||||
hasNote: boolean;
|
||||
hasLabels: boolean;
|
||||
}
|
||||
|
||||
export function getRuleActions(rule: AutomationRule): RuleAction {
|
||||
const hasCategory = rule.action_category_id !== null;
|
||||
const hasNote = rule.action_note !== null;
|
||||
const hasLabels = (rule.labels?.length ?? 0) > 0;
|
||||
|
||||
if (hasCategory && hasNote) {
|
||||
const actionCount =
|
||||
(hasCategory ? 1 : 0) + (hasNote ? 1 : 0) + (hasLabels ? 1 : 0);
|
||||
|
||||
if (actionCount > 1) {
|
||||
return {
|
||||
type: 'both',
|
||||
type: 'multiple',
|
||||
category: rule.category,
|
||||
hasNote: true,
|
||||
labels: rule.labels,
|
||||
hasNote,
|
||||
hasLabels,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -39,25 +49,42 @@ export function getRuleActions(rule: AutomationRule): RuleAction {
|
|||
type: 'category',
|
||||
category: rule.category,
|
||||
hasNote: false,
|
||||
hasLabels: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasLabels) {
|
||||
return {
|
||||
type: 'labels',
|
||||
labels: rule.labels,
|
||||
hasNote: false,
|
||||
hasLabels: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'note',
|
||||
hasNote: true,
|
||||
hasLabels: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRuleActions(rule: AutomationRule): string {
|
||||
const actions = getRuleActions(rule);
|
||||
const parts: string[] = [];
|
||||
|
||||
if (actions.type === 'both') {
|
||||
return `${actions.category?.name} and add note`;
|
||||
if (actions.category) {
|
||||
parts.push(actions.category.name);
|
||||
}
|
||||
|
||||
if (actions.type === 'category') {
|
||||
return actions.category?.name || '';
|
||||
if (actions.hasLabels && actions.labels) {
|
||||
const labelNames = actions.labels.map((l) => l.name).join(', ');
|
||||
parts.push(`labels: ${labelNames}`);
|
||||
}
|
||||
|
||||
return 'Add note';
|
||||
if (actions.hasNote) {
|
||||
parts.push('add note');
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' + ') : 'No actions';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
import { UUID } from './uuid';
|
||||
|
||||
export interface Label {
|
||||
id: UUID;
|
||||
user_id: UUID;
|
||||
name: string;
|
||||
color: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
export const LABEL_COLORS = [
|
||||
'amber',
|
||||
'blue',
|
||||
'cyan',
|
||||
'emerald',
|
||||
'fuchsia',
|
||||
'gray',
|
||||
'green',
|
||||
'indigo',
|
||||
'lime',
|
||||
'neutral',
|
||||
'orange',
|
||||
'pink',
|
||||
'purple',
|
||||
'red',
|
||||
'rose',
|
||||
'slate',
|
||||
'stone',
|
||||
'teal',
|
||||
'violet',
|
||||
'yellow',
|
||||
] as const;
|
||||
|
||||
export type LabelColor = (typeof LABEL_COLORS)[number];
|
||||
|
||||
export function getLabelColorClasses(color: string): {
|
||||
bg: string;
|
||||
text: string;
|
||||
border: string;
|
||||
} {
|
||||
const colorMap: Record<
|
||||
string,
|
||||
{ bg: string; text: string; border: string }
|
||||
> = {
|
||||
amber: {
|
||||
bg: 'bg-amber-100 dark:bg-amber-900/30',
|
||||
text: 'text-amber-700 dark:text-amber-300',
|
||||
border: 'border-amber-300 dark:border-amber-700',
|
||||
},
|
||||
blue: {
|
||||
bg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
text: 'text-blue-700 dark:text-blue-300',
|
||||
border: 'border-blue-300 dark:border-blue-700',
|
||||
},
|
||||
cyan: {
|
||||
bg: 'bg-cyan-100 dark:bg-cyan-900/30',
|
||||
text: 'text-cyan-700 dark:text-cyan-300',
|
||||
border: 'border-cyan-300 dark:border-cyan-700',
|
||||
},
|
||||
emerald: {
|
||||
bg: 'bg-emerald-100 dark:bg-emerald-900/30',
|
||||
text: 'text-emerald-700 dark:text-emerald-300',
|
||||
border: 'border-emerald-300 dark:border-emerald-700',
|
||||
},
|
||||
fuchsia: {
|
||||
bg: 'bg-fuchsia-100 dark:bg-fuchsia-900/30',
|
||||
text: 'text-fuchsia-700 dark:text-fuchsia-300',
|
||||
border: 'border-fuchsia-300 dark:border-fuchsia-700',
|
||||
},
|
||||
gray: {
|
||||
bg: 'bg-gray-100 dark:bg-gray-900/30',
|
||||
text: 'text-gray-700 dark:text-gray-300',
|
||||
border: 'border-gray-300 dark:border-gray-700',
|
||||
},
|
||||
green: {
|
||||
bg: 'bg-green-100 dark:bg-green-900/30',
|
||||
text: 'text-green-700 dark:text-green-300',
|
||||
border: 'border-green-300 dark:border-green-700',
|
||||
},
|
||||
indigo: {
|
||||
bg: 'bg-indigo-100 dark:bg-indigo-900/30',
|
||||
text: 'text-indigo-700 dark:text-indigo-300',
|
||||
border: 'border-indigo-300 dark:border-indigo-700',
|
||||
},
|
||||
lime: {
|
||||
bg: 'bg-lime-100 dark:bg-lime-900/30',
|
||||
text: 'text-lime-700 dark:text-lime-300',
|
||||
border: 'border-lime-300 dark:border-lime-700',
|
||||
},
|
||||
neutral: {
|
||||
bg: 'bg-neutral-100 dark:bg-neutral-900/30',
|
||||
text: 'text-neutral-700 dark:text-neutral-300',
|
||||
border: 'border-neutral-300 dark:border-neutral-700',
|
||||
},
|
||||
orange: {
|
||||
bg: 'bg-orange-100 dark:bg-orange-900/30',
|
||||
text: 'text-orange-700 dark:text-orange-300',
|
||||
border: 'border-orange-300 dark:border-orange-700',
|
||||
},
|
||||
pink: {
|
||||
bg: 'bg-pink-100 dark:bg-pink-900/30',
|
||||
text: 'text-pink-700 dark:text-pink-300',
|
||||
border: 'border-pink-300 dark:border-pink-700',
|
||||
},
|
||||
purple: {
|
||||
bg: 'bg-purple-100 dark:bg-purple-900/30',
|
||||
text: 'text-purple-700 dark:text-purple-300',
|
||||
border: 'border-purple-300 dark:border-purple-700',
|
||||
},
|
||||
red: {
|
||||
bg: 'bg-red-100 dark:bg-red-900/30',
|
||||
text: 'text-red-700 dark:text-red-300',
|
||||
border: 'border-red-300 dark:border-red-700',
|
||||
},
|
||||
rose: {
|
||||
bg: 'bg-rose-100 dark:bg-rose-900/30',
|
||||
text: 'text-rose-700 dark:text-rose-300',
|
||||
border: 'border-rose-300 dark:border-rose-700',
|
||||
},
|
||||
slate: {
|
||||
bg: 'bg-slate-100 dark:bg-slate-900/30',
|
||||
text: 'text-slate-700 dark:text-slate-300',
|
||||
border: 'border-slate-300 dark:border-slate-700',
|
||||
},
|
||||
stone: {
|
||||
bg: 'bg-stone-100 dark:bg-stone-900/30',
|
||||
text: 'text-stone-700 dark:text-stone-300',
|
||||
border: 'border-stone-300 dark:border-stone-700',
|
||||
},
|
||||
teal: {
|
||||
bg: 'bg-teal-100 dark:bg-teal-900/30',
|
||||
text: 'text-teal-700 dark:text-teal-300',
|
||||
border: 'border-teal-300 dark:border-teal-700',
|
||||
},
|
||||
violet: {
|
||||
bg: 'bg-violet-100 dark:bg-violet-900/30',
|
||||
text: 'text-violet-700 dark:text-violet-300',
|
||||
border: 'border-violet-300 dark:border-violet-700',
|
||||
},
|
||||
yellow: {
|
||||
bg: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
text: 'text-yellow-700 dark:text-yellow-300',
|
||||
border: 'border-yellow-300 dark:border-yellow-700',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
colorMap[color] || {
|
||||
bg: 'bg-gray-100 dark:bg-gray-900/30',
|
||||
text: 'text-gray-700 dark:text-gray-300',
|
||||
border: 'border-gray-300 dark:border-gray-700',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { type Account, type Bank } from './account';
|
||||
import { type Category } from './category';
|
||||
import { type Label } from './label';
|
||||
import { UUID } from './uuid';
|
||||
|
||||
export type TransactionSource = 'manually_created' | 'imported';
|
||||
|
|
@ -17,6 +18,7 @@ export interface Transaction {
|
|||
notes: string | null;
|
||||
notes_iv: string | null;
|
||||
source: TransactionSource;
|
||||
label_ids?: UUID[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
@ -27,6 +29,7 @@ export interface DecryptedTransaction extends Transaction {
|
|||
account?: Account;
|
||||
category?: Category | null;
|
||||
bank?: Bank;
|
||||
labels?: Label[];
|
||||
}
|
||||
|
||||
export interface TransactionFilters {
|
||||
|
|
@ -36,5 +39,6 @@ export interface TransactionFilters {
|
|||
amountMax: number | null;
|
||||
categoryIds: UUID[];
|
||||
accountIds: UUID[];
|
||||
labelIds: UUID[];
|
||||
searchText: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use App\Http\Controllers\Sync\AccountSyncController;
|
|||
use App\Http\Controllers\Sync\AutomationRuleSyncController;
|
||||
use App\Http\Controllers\Sync\BankSyncController;
|
||||
use App\Http\Controllers\Sync\CategorySyncController;
|
||||
use App\Http\Controllers\Sync\LabelSyncController;
|
||||
use App\Http\Controllers\Sync\TransactionSyncController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ Route::middleware(['web', 'auth'])->group(function () {
|
|||
// Sync
|
||||
Route::prefix('sync')->group(function () {
|
||||
Route::get('categories', [CategorySyncController::class, 'index']);
|
||||
Route::get('labels', [LabelSyncController::class, 'index']);
|
||||
Route::get('accounts', [AccountSyncController::class, 'index']);
|
||||
Route::get('banks', [BankSyncController::class, 'index']);
|
||||
Route::get('automation-rules', [AutomationRuleSyncController::class, 'index']);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Http\Controllers\Settings\AccountController;
|
||||
use App\Http\Controllers\Settings\BankController;
|
||||
use App\Http\Controllers\Settings\CategoryController;
|
||||
use App\Http\Controllers\Settings\LabelController;
|
||||
use App\Http\Controllers\Settings\PasswordController;
|
||||
use App\Http\Controllers\Settings\ProfileController;
|
||||
use App\Http\Controllers\Settings\TwoFactorAuthenticationController;
|
||||
|
|
@ -36,6 +37,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/labels', [LabelController::class, 'index'])->name('labels.index');
|
||||
Route::post('settings/labels', [LabelController::class, 'store'])->name('labels.store');
|
||||
Route::patch('settings/labels/{label}', [LabelController::class, 'update'])->name('labels.update');
|
||||
Route::delete('settings/labels/{label}', [LabelController::class, 'destroy'])->name('labels.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');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\User;
|
||||
|
||||
test('authenticated users can create a label', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$labelData = [
|
||||
'name' => 'Important',
|
||||
'color' => 'blue',
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->post(route('labels.store'), $labelData);
|
||||
|
||||
$response->assertRedirect(route('labels.index'));
|
||||
|
||||
$this->assertDatabaseHas('labels', [
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Important',
|
||||
'color' => 'blue',
|
||||
]);
|
||||
});
|
||||
|
||||
test('label name is required', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('labels.store'), [
|
||||
'color' => 'blue',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['name']);
|
||||
});
|
||||
|
||||
test('label color is required', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('labels.store'), [
|
||||
'name' => 'Important',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['color']);
|
||||
});
|
||||
|
||||
test('label color must be valid', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('labels.store'), [
|
||||
'name' => 'Important',
|
||||
'color' => 'invalid-color',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['color']);
|
||||
});
|
||||
|
||||
test('label names are unique per user', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Label::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Urgent',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('labels.store'), [
|
||||
'name' => 'Urgent',
|
||||
'color' => 'red',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['name']);
|
||||
});
|
||||
|
||||
test('different users can have labels with the same name', function () {
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
Label::factory()->create([
|
||||
'user_id' => $user1->id,
|
||||
'name' => 'Important',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user2)->post(route('labels.store'), [
|
||||
'name' => 'Important',
|
||||
'color' => 'blue',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('labels.index'));
|
||||
|
||||
$this->assertDatabaseHas('labels', [
|
||||
'user_id' => $user2->id,
|
||||
'name' => 'Important',
|
||||
'color' => 'blue',
|
||||
]);
|
||||
});
|
||||
|
||||
test('authenticated users can update their own label', function () {
|
||||
$user = User::factory()->create();
|
||||
$label = Label::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$updateData = [
|
||||
'name' => 'Updated Name',
|
||||
'color' => 'green',
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->patch(
|
||||
route('labels.update', $label),
|
||||
$updateData
|
||||
);
|
||||
|
||||
$response->assertRedirect(route('labels.index'));
|
||||
|
||||
$this->assertDatabaseHas('labels', [
|
||||
'id' => $label->id,
|
||||
'name' => 'Updated Name',
|
||||
'color' => 'green',
|
||||
]);
|
||||
});
|
||||
|
||||
test('users cannot update labels they do not own', function () {
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$label = Label::factory()->create(['user_id' => $otherUser->id]);
|
||||
|
||||
$response = $this->actingAs($user)->patch(
|
||||
route('labels.update', $label),
|
||||
[
|
||||
'name' => 'Updated Name',
|
||||
'color' => 'green',
|
||||
]
|
||||
);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('authenticated users can delete their own label', function () {
|
||||
$user = User::factory()->create();
|
||||
$label = Label::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('labels.destroy', $label));
|
||||
|
||||
$response->assertRedirect(route('labels.index'));
|
||||
|
||||
$this->assertSoftDeleted('labels', [
|
||||
'id' => $label->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('users cannot delete labels they do not own', function () {
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$label = Label::factory()->create(['user_id' => $otherUser->id]);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('labels.destroy', $label));
|
||||
|
||||
$response->assertForbidden();
|
||||
|
||||
$this->assertDatabaseHas('labels', [
|
||||
'id' => $label->id,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('guests cannot access label management', function () {
|
||||
$response = $this->postJson(route('labels.store'), []);
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('creating a label with the same name as a deleted label creates a new one', function () {
|
||||
$user = User::factory()->create();
|
||||
$label = Label::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Wedding',
|
||||
'color' => 'blue',
|
||||
]);
|
||||
$originalId = $label->id;
|
||||
|
||||
$this->actingAs($user)->delete(route('labels.destroy', $label));
|
||||
|
||||
$this->assertSoftDeleted('labels', ['id' => $originalId]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('labels.store'), [
|
||||
'name' => 'Wedding',
|
||||
'color' => 'teal',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$newLabel = Label::where('user_id', $user->id)
|
||||
->where('name', 'Wedding')
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
expect($newLabel)->not->toBeNull();
|
||||
expect($newLabel->id)->not->toBe($originalId);
|
||||
expect($newLabel->color)->toBe('teal');
|
||||
|
||||
$this->assertSoftDeleted('labels', ['id' => $originalId]);
|
||||
});
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\User;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
use function Pest\Laravel\getJson;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
});
|
||||
|
||||
it('returns all labels for authenticated user', function () {
|
||||
actingAs($this->user);
|
||||
|
||||
Label::factory()
|
||||
->count(3)
|
||||
->create(['user_id' => $this->user->id]);
|
||||
|
||||
Label::factory()
|
||||
->count(2)
|
||||
->create();
|
||||
|
||||
$response = getJson('/api/sync/labels');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(3, 'data');
|
||||
});
|
||||
|
||||
it('returns only labels updated after specified timestamp', function () {
|
||||
actingAs($this->user);
|
||||
|
||||
$oldLabel = Label::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'updated_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$newLabel = Label::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$response = getJson('/api/sync/labels?since='.now()->subDay()->toIso8601String());
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(1, 'data');
|
||||
$response->assertJsonPath('data.0.id', $newLabel->id);
|
||||
});
|
||||
|
||||
it('requires authentication', function () {
|
||||
$response = getJson('/api/sync/labels');
|
||||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
Loading…
Reference in New Issue