Transactions page
This commit is contained in:
parent
faf6d9146e
commit
509065e28d
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdateTransactionRequest;
|
||||
use App\Models\Account;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class TransactionController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$categories = Category::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'icon', 'color']);
|
||||
|
||||
$accounts = Account::query()
|
||||
->where('user_id', $user->id)
|
||||
->with('bank:id,name,logo')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']);
|
||||
|
||||
$banks = Bank::query()
|
||||
->where(function ($q) use ($user) {
|
||||
$q->whereNull('user_id')
|
||||
->orWhere('user_id', $user->id);
|
||||
})
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'logo']);
|
||||
|
||||
return Inertia::render('transactions/index', [
|
||||
'categories' => $categories,
|
||||
'accounts' => $accounts,
|
||||
'banks' => $banks,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateTransactionRequest $request, Transaction $transaction): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $transaction);
|
||||
|
||||
$transaction->update($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'data' => $transaction->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Transaction $transaction): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $transaction);
|
||||
|
||||
$transaction->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Transaction deleted successfully',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateTransactionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => ['nullable', 'exists:categories,id'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
'notes_iv' => ['nullable', 'string', 'size:16'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'category_id.exists' => 'The selected category does not exist.',
|
||||
'notes_iv.size' => 'The notes IV must be exactly 16 characters.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -6,11 +6,12 @@ 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\SoftDeletes;
|
||||
|
||||
class Transaction extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\TransactionFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
use HasFactory, HasUuids, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
|
||||
class TransactionPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function view(User $user, Transaction $transaction): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function update(User $user, Transaction $transaction): bool
|
||||
{
|
||||
return $user->id === $transaction->user_id;
|
||||
}
|
||||
|
||||
public function delete(User $user, Transaction $transaction): bool
|
||||
{
|
||||
return $user->id === $transaction->user_id;
|
||||
}
|
||||
|
||||
public function restore(User $user, Transaction $transaction): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, Transaction $transaction): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?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('transactions', function (Blueprint $table) {
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('transactions', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -30,9 +30,10 @@ import { UserMenuContent } from '@/components/user-menu-content';
|
|||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { cn, isSameUrl, resolveUrl } from '@/lib/utils';
|
||||
import { dashboard } from '@/routes';
|
||||
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
||||
import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react';
|
||||
import { BookOpen, Folder, LayoutGrid, Menu, Receipt, Search } from 'lucide-react';
|
||||
import AppLogo from './app-logo';
|
||||
import AppLogoIcon from './app-logo-icon';
|
||||
|
||||
|
|
@ -42,6 +43,11 @@ const mainNavItems: NavItem[] = [
|
|||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Transactions',
|
||||
href: transactionsIndex(),
|
||||
icon: Receipt,
|
||||
},
|
||||
];
|
||||
|
||||
const rightNavItems: NavItem[] = [
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import {
|
|||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { dashboard } from '@/routes';
|
||||
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
||||
import { type NavItem } from '@/types';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { BookOpen, Folder, LayoutGrid } from 'lucide-react';
|
||||
import { BookOpen, Folder, LayoutGrid, Receipt } from 'lucide-react';
|
||||
import AppLogo from './app-logo';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
|
|
@ -22,6 +23,11 @@ const mainNavItems: NavItem[] = [
|
|||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Transactions',
|
||||
href: transactionsIndex(),
|
||||
icon: Receipt,
|
||||
},
|
||||
];
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
|
|
|
|||
|
|
@ -1,122 +1,138 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { importKey, decrypt } from '@/lib/crypto';
|
||||
|
||||
type Length = number | { min: number; max: number } | null;
|
||||
|
||||
interface EncryptedTextProps {
|
||||
encryptedText: string;
|
||||
iv: string;
|
||||
interval?: number;
|
||||
className?: string;
|
||||
fallback?: string;
|
||||
length: Length;
|
||||
}
|
||||
|
||||
const chars = "-_~`!@#$%^&*()+=[]{}|;:,.<>?";
|
||||
|
||||
function generateRandomChars(length: number): string {
|
||||
return Array.from({ length }, () =>
|
||||
chars[Math.floor(Math.random() * chars.length)]
|
||||
).join('');
|
||||
function randInt(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
export function EncryptedText({
|
||||
encryptedText,
|
||||
iv,
|
||||
interval = 30,
|
||||
className = '',
|
||||
fallback = '[Encrypted]',
|
||||
}: EncryptedTextProps) {
|
||||
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
export function EncryptedText(props: EncryptedTextProps) {
|
||||
const { encryptedText, iv, className = '', length = null } = props;
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const [decryptedText, setDecryptedText] = useState<string | null>(null);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const isFirstLoad = useRef(true);
|
||||
const fallbackCharsRef = useRef<string>(generateRandomChars(fallback.length));
|
||||
const [targetText, setTargetText] = useState(() => fallbackCharsRef.current);
|
||||
const [outputText, setOutputText] = useState(() => fallbackCharsRef.current);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function decryptData() {
|
||||
const keyString = getStoredKey();
|
||||
if (!keyString || !isKeySet) {
|
||||
setDecryptedText(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = await importKey(keyString);
|
||||
const text = await decrypt(encryptedText, key, iv);
|
||||
setDecryptedText(text);
|
||||
} catch (err) {
|
||||
console.error('Failed to decrypt text:', err);
|
||||
setDecryptedText(null);
|
||||
}
|
||||
const maxLength = useMemo<number>(() => {
|
||||
if (typeof length === 'number') {
|
||||
return length;
|
||||
}
|
||||
|
||||
decryptData();
|
||||
}, [encryptedText, iv, isKeySet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (decryptedText !== null && isKeySet) {
|
||||
setTargetText(decryptedText);
|
||||
} else {
|
||||
setTargetText(fallbackCharsRef.current);
|
||||
if (length && typeof length === 'object' && 'max' in length) {
|
||||
return randInt(length.min, length.max)
|
||||
}
|
||||
}, [decryptedText, isKeySet]);
|
||||
|
||||
return randInt(10, 20)
|
||||
}, [length]);
|
||||
const [cachedDecryption, setCachedDecryption] = useState<{ encryptedText: string; iv: string; value: string; } | null>(null);
|
||||
const maskedText = useMemo(() => {
|
||||
if (isKeySet && cachedDecryption?.value) {
|
||||
return cachedDecryption.value;
|
||||
}
|
||||
|
||||
return encryptedText.slice(0, maxLength);
|
||||
}, [cachedDecryption?.value, encryptedText, isKeySet, maxLength]);
|
||||
const [displayValue, setDisplayValue] = useState<string>(maskedText);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
const displayValueRef = useRef(displayValue);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
if (isFirstLoad.current) {
|
||||
setOutputText(targetText);
|
||||
isFirstLoad.current = false;
|
||||
if (!isKeySet) {
|
||||
setCachedDecryption(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (outputText !== targetText) {
|
||||
setIsAnimating(true);
|
||||
let currentIndex = 0;
|
||||
|
||||
timer = setInterval(() => {
|
||||
if (currentIndex < targetText.length) {
|
||||
setOutputText(targetText.slice(0, currentIndex + 1));
|
||||
currentIndex++;
|
||||
} else {
|
||||
clearInterval(timer);
|
||||
setIsAnimating(false);
|
||||
}
|
||||
}, interval);
|
||||
if (
|
||||
cachedDecryption &&
|
||||
cachedDecryption.encryptedText === encryptedText &&
|
||||
cachedDecryption.iv === iv
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyString = getStoredKey();
|
||||
if (!keyString) {
|
||||
setCachedDecryption(null);
|
||||
return;
|
||||
}
|
||||
|
||||
importKey(keyString)
|
||||
.then((key) => decrypt(encryptedText, key, iv))
|
||||
.then((value) => {
|
||||
setCachedDecryption({ encryptedText, iv, value });
|
||||
})
|
||||
.catch(() => {
|
||||
setCachedDecryption(null);
|
||||
});
|
||||
}, [encryptedText, iv, isKeySet]);
|
||||
|
||||
useEffect(() => {
|
||||
displayValueRef.current = displayValue;
|
||||
}, [displayValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) {
|
||||
window.clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
if (maskedText === displayValueRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!maskedText.length) {
|
||||
displayValueRef.current = '';
|
||||
setDisplayValue('');
|
||||
return;
|
||||
}
|
||||
|
||||
let iteration = 0;
|
||||
const target = maskedText;
|
||||
const targetLength = target.length;
|
||||
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
iteration += 1;
|
||||
|
||||
if (iteration >= targetLength) {
|
||||
if (intervalRef.current) {
|
||||
window.clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
displayValueRef.current = target;
|
||||
setDisplayValue(target);
|
||||
return;
|
||||
}
|
||||
|
||||
const revealCount = Math.floor(iteration);
|
||||
|
||||
const randomValue = Array.from({ length: targetLength }, (_, index) => {
|
||||
if (index < revealCount) {
|
||||
return target[index];
|
||||
}
|
||||
|
||||
return LETTERS[Math.floor(Math.random() * LETTERS.length)];
|
||||
}).join('');
|
||||
|
||||
displayValueRef.current = randomValue;
|
||||
setDisplayValue(randomValue);
|
||||
}, 20);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
setIsAnimating(false);
|
||||
if (intervalRef.current) {
|
||||
window.clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [targetText, interval]);
|
||||
}, [maskedText]);
|
||||
|
||||
const remainder =
|
||||
outputText.length < targetText.length
|
||||
? targetText
|
||||
.slice(outputText.length)
|
||||
.split('')
|
||||
.map(() => chars[Math.floor(Math.random() * chars.length)])
|
||||
.join('')
|
||||
: '';
|
||||
|
||||
if (!isMounted) {
|
||||
return <span className={className}> </span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`${className} ${isAnimating ? 'animate-pulse' : ''}`}>
|
||||
{outputText}
|
||||
{remainder}
|
||||
</span>
|
||||
);
|
||||
return <span className={className}>{displayValue}</span>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import * as Icons from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { encrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
|
||||
interface CategoryCellProps {
|
||||
transaction: DecryptedTransaction;
|
||||
categories: Category[];
|
||||
accounts: Account[];
|
||||
banks: Bank[];
|
||||
onUpdate: (transaction: DecryptedTransaction) => void;
|
||||
}
|
||||
|
||||
export function CategoryCell({
|
||||
transaction,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
onUpdate,
|
||||
}: CategoryCellProps) {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
async function handleCategoryChange(value: string) {
|
||||
const categoryId = value === 'null' ? null : parseInt(value);
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const updateData: {
|
||||
category_id: number | null;
|
||||
notes?: string;
|
||||
notes_iv?: string;
|
||||
} = {
|
||||
category_id: categoryId,
|
||||
};
|
||||
|
||||
if (transaction.notes) {
|
||||
const keyString = getStoredKey();
|
||||
if (!keyString) {
|
||||
throw new Error('Encryption key not available');
|
||||
}
|
||||
const key = await importKey(keyString);
|
||||
const encrypted = await encrypt(transaction.notes, key);
|
||||
updateData.notes = encrypted.encrypted;
|
||||
updateData.notes_iv = encrypted.iv;
|
||||
}
|
||||
|
||||
await transactionSyncService.update(transaction.id, updateData);
|
||||
|
||||
const updatedCategory = categoryId
|
||||
? categories.find((c) => c.id === categoryId) || null
|
||||
: null;
|
||||
|
||||
const account = accounts.find((a) => a.id === transaction.account_id);
|
||||
const bank = account?.bank?.id
|
||||
? banks.find((b) => b.id === account.bank.id)
|
||||
: undefined;
|
||||
|
||||
const updatedTransaction: DecryptedTransaction = {
|
||||
...transaction,
|
||||
category_id: categoryId,
|
||||
category: updatedCategory,
|
||||
account,
|
||||
bank,
|
||||
};
|
||||
|
||||
onUpdate(updatedTransaction);
|
||||
} catch (error) {
|
||||
console.error('Failed to update category:', error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const currentCategory = transaction.category;
|
||||
const colorClasses = currentCategory
|
||||
? getCategoryColorClasses(currentCategory.color)
|
||||
: null;
|
||||
const CurrentCategoryIconComponent = (currentCategory ? Icons[currentCategory.icon as keyof typeof Icons] : "CircleQuestionMark") as Icons.LucideIcon;
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={
|
||||
transaction.category_id
|
||||
? String(transaction.category_id)
|
||||
: 'null'
|
||||
}
|
||||
onValueChange={handleCategoryChange}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<SelectTrigger className="h-auto w-auto border-0 bg-transparent p-0 shadow-none focus:ring-0">
|
||||
<SelectValue>
|
||||
{currentCategory ? (
|
||||
<Badge
|
||||
className={`${colorClasses?.bg} ${colorClasses?.text} flex gap-2`}
|
||||
>
|
||||
<CurrentCategoryIconComponent className={`opacity-80 h-2 w-2 ${colorClasses?.text}`} />
|
||||
{currentCategory.name}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="text-zinc-500 bg-zinc-50 dark:bg-zinc-950 flex gap-2">
|
||||
<Icons.CircleQuestionMark className={`opacity-80 h-2 w-2 text-zinc-500`} />
|
||||
Uncategorized
|
||||
</Badge>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => {
|
||||
const IconComponent = Icons[category.icon as keyof typeof Icons] as Icons.LucideIcon;
|
||||
const classes = getCategoryColorClasses(category.color);
|
||||
return (
|
||||
<SelectItem key={category.id} value={String(category.id)}>
|
||||
<Badge className={`flex items-center gap-2 py-0.5 ${classes.bg} ${classes.text}`}>
|
||||
<IconComponent className={`opacity-80 h-2 w-2 ${classes.text}`} />
|
||||
<span>{category.name}</span>
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { encrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
interface EditTransactionDialogProps {
|
||||
transaction: DecryptedTransaction | null;
|
||||
categories: Category[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function EditTransactionDialog({
|
||||
transaction,
|
||||
categories,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: EditTransactionDialogProps) {
|
||||
const [categoryId, setCategoryId] = useState<string>('null');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (transaction) {
|
||||
setCategoryId(
|
||||
transaction.category_id ? String(transaction.category_id) : 'null',
|
||||
);
|
||||
setNotes(transaction.decryptedNotes || '');
|
||||
}
|
||||
}, [transaction]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!transaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const updateData: {
|
||||
category_id: number | null;
|
||||
notes?: string;
|
||||
notes_iv?: string;
|
||||
} = {
|
||||
category_id: categoryId === 'null' ? null : parseInt(categoryId),
|
||||
};
|
||||
|
||||
if (notes.trim()) {
|
||||
const keyString = getStoredKey();
|
||||
if (!keyString) {
|
||||
throw new Error('Encryption key not available');
|
||||
}
|
||||
const key = await importKey(keyString);
|
||||
const encrypted = await encrypt(notes, key);
|
||||
updateData.notes = encrypted.encrypted;
|
||||
updateData.notes_iv = encrypted.iv;
|
||||
} else {
|
||||
updateData.notes = null;
|
||||
updateData.notes_iv = null;
|
||||
}
|
||||
|
||||
await transactionSyncService.update(transaction.id, updateData);
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to update transaction:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!transaction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedCategory = categories.find(
|
||||
(c) => c.id === parseInt(categoryId),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[525px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Transaction</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the category and notes for this transaction.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground text-sm">
|
||||
Date
|
||||
</Label>
|
||||
<div className="text-sm">
|
||||
{format(parseISO(transaction.transaction_date), 'PPP')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground text-sm">
|
||||
Description
|
||||
</Label>
|
||||
<div className="text-sm">
|
||||
{transaction.decryptedDescription}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground text-sm">
|
||||
Amount
|
||||
</Label>
|
||||
<div className="text-sm font-medium">
|
||||
{new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: transaction.currency_code,
|
||||
}).format(parseFloat(transaction.amount))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category</Label>
|
||||
<Select
|
||||
value={categoryId}
|
||||
onValueChange={setCategoryId}
|
||||
>
|
||||
<SelectTrigger id="category">
|
||||
<SelectValue>
|
||||
{selectedCategory ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{selectedCategory.icon}</span>
|
||||
<span>{selectedCategory.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-zinc-500">
|
||||
Uncategorized
|
||||
</span>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="null">
|
||||
<span className="text-zinc-500">
|
||||
Uncategorized
|
||||
</span>
|
||||
</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem
|
||||
key={category.id}
|
||||
value={String(category.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{category.icon}</span>
|
||||
<span>{category.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder="Add notes..."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ export function ImportStepAccount({
|
|||
<EncryptedText
|
||||
encryptedText={account.name}
|
||||
iv={account.name_iv}
|
||||
fallback="[Encrypted Account]"
|
||||
length={19}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { ArrowDown, ArrowUpDown, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { CategoryCell } from '@/components/transactions/category-cell';
|
||||
import { EncryptedText } from '@/components/encrypted-text';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
|
||||
interface CreateColumnsOptions {
|
||||
categories: Category[];
|
||||
accounts: Account[];
|
||||
banks: Bank[];
|
||||
onEdit: (transaction: DecryptedTransaction) => void;
|
||||
onDelete: (transaction: DecryptedTransaction) => void;
|
||||
onUpdate: (transaction: DecryptedTransaction) => void;
|
||||
}
|
||||
|
||||
export function createTransactionColumns({
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
}: CreateColumnsOptions): ColumnDef<DecryptedTransaction>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'transaction_date',
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
Date
|
||||
<ArrowDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="font-medium pl-3">
|
||||
{format(parseISO(row.getValue('transaction_date')), 'PP')}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'category_id',
|
||||
header: 'Category',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<CategoryCell
|
||||
transaction={row.original}
|
||||
categories={categories}
|
||||
accounts={accounts}
|
||||
banks={banks}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'decryptedDescription',
|
||||
header: 'Description',
|
||||
cell: ({ row }) => {
|
||||
const transaction = row.original;
|
||||
return (
|
||||
<div className="max-w-[150px] md:max-w-[250px] lg:max-w-[500px] truncate">
|
||||
<EncryptedText
|
||||
encryptedText={transaction.description}
|
||||
iv={transaction.description_iv}
|
||||
length={{ min: 20, max: 80 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'bank',
|
||||
header: 'Bank',
|
||||
cell: ({ row }) => {
|
||||
const bank = row.original.bank;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{bank?.logo && (
|
||||
<img
|
||||
src={bank.logo}
|
||||
alt={bank.name}
|
||||
className="h-6 w-6 rounded"
|
||||
/>
|
||||
)}
|
||||
<span>{bank?.name || 'N/A'}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'account',
|
||||
header: 'Account',
|
||||
cell: ({ row }) => {
|
||||
const account = row.original.account;
|
||||
return <div>{account?.name || 'N/A'}</div>;
|
||||
},
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
header: () => {
|
||||
return (
|
||||
<div className="w-full text-right">
|
||||
Amount
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const amount = parseFloat(row.getValue('amount'));
|
||||
const currencyCode = row.original.currency_code;
|
||||
|
||||
const formatted = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
}).format(amount);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`text-right`}
|
||||
>
|
||||
<span className={`${amount < 0 ? '' : 'bg-green-100/70 dark:bg-green-900'}`}>{formatted}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const transaction = row.original;
|
||||
|
||||
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={() => onEdit(transaction)}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant='destructive'
|
||||
onClick={() => onDelete(transaction)}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
import { useState } from 'react';
|
||||
import { Calendar, X } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Account } from '@/types/account';
|
||||
import { type TransactionFilters } from '@/types/transaction';
|
||||
|
||||
interface TransactionFiltersProps {
|
||||
filters: TransactionFilters;
|
||||
onFiltersChange: (filters: TransactionFilters) => void;
|
||||
categories: Category[];
|
||||
accounts: Account[];
|
||||
isKeySet: boolean;
|
||||
}
|
||||
|
||||
export function TransactionFilters({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
categories,
|
||||
accounts,
|
||||
isKeySet,
|
||||
}: TransactionFiltersProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
function handleCategoryToggle(categoryId: number) {
|
||||
const newCategoryIds = filters.categoryIds.includes(categoryId)
|
||||
? filters.categoryIds.filter((id) => id !== categoryId)
|
||||
: [...filters.categoryIds, categoryId];
|
||||
|
||||
onFiltersChange({ ...filters, categoryIds: newCategoryIds });
|
||||
}
|
||||
|
||||
function handleAccountToggle(accountId: number) {
|
||||
const newAccountIds = filters.accountIds.includes(accountId)
|
||||
? filters.accountIds.filter((id) => id !== accountId)
|
||||
: [...filters.accountIds, accountId];
|
||||
|
||||
onFiltersChange({ ...filters, accountIds: newAccountIds });
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
onFiltersChange({
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
amountMin: null,
|
||||
amountMax: null,
|
||||
categoryIds: [],
|
||||
accountIds: [],
|
||||
searchText: '',
|
||||
});
|
||||
}
|
||||
|
||||
const activeFilterCount =
|
||||
(filters.dateFrom ? 1 : 0) +
|
||||
(filters.dateTo ? 1 : 0) +
|
||||
(filters.amountMin !== null ? 1 : 0) +
|
||||
(filters.amountMax !== null ? 1 : 0) +
|
||||
filters.categoryIds.length +
|
||||
filters.accountIds.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder={
|
||||
isKeySet
|
||||
? 'Search description or notes...'
|
||||
: 'Search disabled (encryption key not set)'
|
||||
}
|
||||
value={filters.searchText}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
searchText: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={!isKeySet}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline">
|
||||
Filters
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-2 rounded-full px-1.5 py-0.5"
|
||||
>
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-96" align="start">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">Filters</h4>
|
||||
{activeFilterCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Date Range</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={
|
||||
filters.dateFrom
|
||||
? format(filters.dateFrom, 'yyyy-MM-dd')
|
||||
: ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
dateFrom: e.target.value
|
||||
? new Date(e.target.value)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
placeholder="From"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={
|
||||
filters.dateTo
|
||||
? format(filters.dateTo, 'yyyy-MM-dd')
|
||||
: ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
dateTo: e.target.value
|
||||
? new Date(e.target.value)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
placeholder="To"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Amount Range</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={filters.amountMin ?? ''}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
amountMin: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
placeholder="Min"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={filters.amountMax ?? ''}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
amountMax: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
placeholder="Max"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Categories</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => {
|
||||
const isSelected =
|
||||
filters.categoryIds.includes(category.id);
|
||||
return (
|
||||
<Badge
|
||||
key={category.id}
|
||||
variant={
|
||||
isSelected ? 'default' : 'outline'
|
||||
}
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
handleCategoryToggle(category.id)
|
||||
}
|
||||
>
|
||||
{category.icon} {category.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Accounts</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{accounts.map((account) => {
|
||||
const isSelected =
|
||||
filters.accountIds.includes(account.id);
|
||||
return (
|
||||
<Badge
|
||||
key={account.id}
|
||||
variant={
|
||||
isSelected ? 'default' : 'outline'
|
||||
}
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
handleAccountToggle(account.id)
|
||||
}
|
||||
>
|
||||
{account.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{activeFilterCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="h-9"
|
||||
>
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return <AlertDialogPrimitive.Trigger {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return <AlertDialogPrimitive.Portal {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { Table as TableType } from '@tanstack/react-table';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: TableType<TData>;
|
||||
rowCountLabel?: string;
|
||||
displayedCount?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
displayedCount = undefined,
|
||||
total = undefined,
|
||||
rowCountLabel = 'row(s) total',
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
return (
|
||||
<div className="flex px-5 items-center justify-end space-x-2">
|
||||
{displayedCount && total && (
|
||||
<div className="text-muted-foreground flex-1 text-sm">
|
||||
{displayedCount} of {total} {rowCountLabel}
|
||||
</div>
|
||||
)}
|
||||
{displayedCount && total === undefined && (
|
||||
<div className="text-muted-foreground flex-1 text-sm">
|
||||
{displayedCount} {rowCountLabel}
|
||||
</div>
|
||||
)}
|
||||
{((displayedCount ?? 0) < (total ?? 0)) && <div className="space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled
|
||||
>
|
||||
Loading <RefreshCw className="ml-2 h-4 w-4 animate-spin" />
|
||||
</Button>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
Table as TableType,
|
||||
} from '@tanstack/react-table';
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
table: TableType<TData>;
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
table,
|
||||
columns,
|
||||
emptyMessage = 'No results found.',
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Textarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border px-3 py-2 text-base focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
|
||||
|
|
@ -145,6 +145,7 @@ export default function Accounts() {
|
|||
<div className="pl-3 font-medium">
|
||||
<EncryptedText
|
||||
encryptedText={row.original.name}
|
||||
length={{ min: 10, max: 20 }}
|
||||
iv={row.original.name_iv}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -259,11 +260,11 @@ export default function Accounts() {
|
|||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
header.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,489 @@
|
|||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import {
|
||||
ColumnFiltersState,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { parseISO, isWithinInterval } from 'date-fns';
|
||||
|
||||
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
||||
import { DataTable } from '@/components/ui/data-table';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import { DataTablePagination } from '@/components/ui/data-table-pagination';
|
||||
import { TransactionFilters } from '@/components/transactions/transaction-filters';
|
||||
import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog';
|
||||
import { createTransactionColumns } from '@/components/transactions/transaction-columns';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import {
|
||||
type DecryptedTransaction,
|
||||
type TransactionFilters as Filters,
|
||||
} from '@/types/transaction';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { decrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Transactions',
|
||||
href: transactionsIndex.url(),
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
categories: Category[];
|
||||
accounts: Account[];
|
||||
banks: Bank[];
|
||||
}
|
||||
|
||||
export default function Transactions({ categories, accounts, banks }: Props) {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const [transactions, setTransactions] = useState<DecryptedTransaction[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'transaction_date', desc: true },
|
||||
]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
account: false,
|
||||
});
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
amountMin: null,
|
||||
amountMax: null,
|
||||
categoryIds: [],
|
||||
accountIds: [],
|
||||
searchText: '',
|
||||
});
|
||||
const [editTransaction, setEditTransaction] =
|
||||
useState<DecryptedTransaction | null>(null);
|
||||
const [deleteTransaction, setDeleteTransaction] =
|
||||
useState<DecryptedTransaction | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [displayedCount, setDisplayedCount] = useState(25);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
|
||||
function updateTransaction(updatedTransaction: DecryptedTransaction) {
|
||||
setTransactions((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === updatedTransaction.id ? updatedTransaction : t
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function loadTransactions() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const rawTransactions = await transactionSyncService.getAll();
|
||||
const accountsMap = new Map(
|
||||
accounts.map((account) => [account.id, account]),
|
||||
);
|
||||
const categoriesMap = new Map(
|
||||
categories.map((category) => [category.id, category]),
|
||||
);
|
||||
const banksMap = new Map(banks.map((bank) => [bank.id, bank]));
|
||||
|
||||
const keyString = getStoredKey();
|
||||
let key: CryptoKey | null = null;
|
||||
|
||||
if (keyString && isKeySet) {
|
||||
try {
|
||||
key = await importKey(keyString);
|
||||
} catch (error) {
|
||||
console.error('Failed to import encryption key:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const decrypted = await Promise.all(
|
||||
rawTransactions.map(async (transaction) => {
|
||||
try {
|
||||
let decryptedDescription = '';
|
||||
let decryptedNotes: string | null = null;
|
||||
|
||||
if (key) {
|
||||
try {
|
||||
decryptedDescription = await decrypt(
|
||||
transaction.description,
|
||||
key,
|
||||
transaction.description_iv,
|
||||
);
|
||||
|
||||
if (transaction.notes && transaction.notes_iv) {
|
||||
decryptedNotes = await decrypt(
|
||||
transaction.notes,
|
||||
key,
|
||||
transaction.notes_iv,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to decrypt transaction:',
|
||||
transaction.id,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const account = accountsMap.get(transaction.account_id);
|
||||
const category = transaction.category_id
|
||||
? categoriesMap.get(transaction.category_id)
|
||||
: null;
|
||||
const bank = account?.bank?.id
|
||||
? banksMap.get(account.bank.id)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...transaction,
|
||||
decryptedDescription,
|
||||
decryptedNotes,
|
||||
account,
|
||||
category: category || null,
|
||||
bank,
|
||||
} as DecryptedTransaction;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to process transaction:',
|
||||
transaction.id,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setTransactions(
|
||||
decrypted.filter(
|
||||
(t): t is DecryptedTransaction => t !== null,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to load transactions:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTransactions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function reDecryptTransactions() {
|
||||
if (transactions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyString = getStoredKey();
|
||||
let key: CryptoKey | null = null;
|
||||
|
||||
if (keyString && isKeySet) {
|
||||
try {
|
||||
key = await importKey(keyString);
|
||||
} catch (error) {
|
||||
console.error('Failed to import encryption key:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const reDecrypted = await Promise.all(
|
||||
transactions.map(async (transaction) => {
|
||||
try {
|
||||
let decryptedDescription = '';
|
||||
let decryptedNotes: string | null = null;
|
||||
|
||||
if (key) {
|
||||
try {
|
||||
decryptedDescription = await decrypt(
|
||||
transaction.description,
|
||||
key,
|
||||
transaction.description_iv,
|
||||
);
|
||||
|
||||
if (transaction.notes && transaction.notes_iv) {
|
||||
decryptedNotes = await decrypt(
|
||||
transaction.notes,
|
||||
key,
|
||||
transaction.notes_iv,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to decrypt transaction:',
|
||||
transaction.id,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...transaction,
|
||||
decryptedDescription,
|
||||
decryptedNotes,
|
||||
} as DecryptedTransaction;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to process transaction:',
|
||||
transaction.id,
|
||||
error,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setTransactions(reDecrypted);
|
||||
}
|
||||
|
||||
reDecryptTransactions();
|
||||
}, [isKeySet]);
|
||||
|
||||
const filteredTransactions = useMemo(() => {
|
||||
return transactions.filter((transaction) => {
|
||||
if (filters.dateFrom || filters.dateTo) {
|
||||
const transactionDate = parseISO(transaction.transaction_date);
|
||||
if (
|
||||
filters.dateFrom &&
|
||||
filters.dateTo &&
|
||||
!isWithinInterval(transactionDate, {
|
||||
start: filters.dateFrom,
|
||||
end: filters.dateTo,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (filters.dateFrom && transactionDate < filters.dateFrom) {
|
||||
return false;
|
||||
}
|
||||
if (filters.dateTo && transactionDate > filters.dateTo) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
filters.amountMin !== null &&
|
||||
parseFloat(transaction.amount) < filters.amountMin
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filters.amountMax !== null &&
|
||||
parseFloat(transaction.amount) > filters.amountMax
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
filters.categoryIds.length > 0 &&
|
||||
!filters.categoryIds.includes(transaction.category_id || -1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
filters.accountIds.length > 0 &&
|
||||
!filters.accountIds.includes(transaction.account_id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.searchText && isKeySet) {
|
||||
const searchLower = filters.searchText.toLowerCase();
|
||||
const matchesDescription =
|
||||
transaction.decryptedDescription
|
||||
.toLowerCase()
|
||||
.includes(searchLower);
|
||||
const matchesNotes =
|
||||
transaction.decryptedNotes
|
||||
?.toLowerCase()
|
||||
.includes(searchLower) || false;
|
||||
|
||||
if (!matchesDescription && !matchesNotes) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [transactions, filters, isKeySet]);
|
||||
|
||||
const displayedTransactions = useMemo(() => {
|
||||
return filteredTransactions.slice(0, displayedCount);
|
||||
}, [filteredTransactions, displayedCount]);
|
||||
|
||||
const columns = createTransactionColumns({
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
onEdit: setEditTransaction,
|
||||
onDelete: setDeleteTransaction,
|
||||
onUpdate: updateTransaction,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data: displayedTransactions,
|
||||
columns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getRowId: (row) => row.id.toString(),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
},
|
||||
});
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (displayedCount < filteredTransactions.length) {
|
||||
setDisplayedCount((prev) => Math.min(prev + 25, filteredTransactions.length));
|
||||
}
|
||||
}, [displayedCount, filteredTransactions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
const currentTarget = observerTarget.current;
|
||||
if (currentTarget) {
|
||||
observer.observe(currentTarget);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentTarget) {
|
||||
observer.unobserve(currentTarget);
|
||||
}
|
||||
};
|
||||
}, [loadMore]);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayedCount(25);
|
||||
}, [filters]);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTransaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await transactionSyncService.delete(deleteTransaction.id);
|
||||
await loadTransactions();
|
||||
setDeleteTransaction(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete transaction:', error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppSidebarLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Transactions" />
|
||||
|
||||
<div className="space-y-6 p-6">
|
||||
<HeadingSmall
|
||||
title="Transactions"
|
||||
description="View and manage your transactions"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<TransactionFilters
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
categories={categories}
|
||||
accounts={accounts}
|
||||
isKeySet={isKeySet}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-muted-foreground">
|
||||
Loading transactions...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
emptyMessage="No transactions found."
|
||||
/>
|
||||
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
displayedCount={displayedCount}
|
||||
total={filteredTransactions.length}
|
||||
rowCountLabel="transactions total"
|
||||
/>
|
||||
|
||||
<div ref={observerTarget} className="h-0" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditTransactionDialog
|
||||
transaction={editTransaction}
|
||||
categories={categories}
|
||||
open={!!editTransaction}
|
||||
onOpenChange={(open) => !open && setEditTransaction(null)}
|
||||
onSuccess={loadTransactions}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={!!deleteTransaction}
|
||||
onOpenChange={(open) => !open && setDeleteTransaction(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Transaction</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this transaction?
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</AppSidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { type Account, type Bank } from './account';
|
||||
import { type Category } from './category';
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
user_id: number;
|
||||
account_id: number;
|
||||
category_id: number | null;
|
||||
description: string;
|
||||
description_iv: string;
|
||||
transaction_date: string;
|
||||
amount: string;
|
||||
currency_code: string;
|
||||
notes: string | null;
|
||||
notes_iv: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DecryptedTransaction extends Transaction {
|
||||
decryptedDescription: string;
|
||||
decryptedNotes: string | null;
|
||||
account?: Account;
|
||||
category?: Category | null;
|
||||
bank?: Bank;
|
||||
}
|
||||
|
||||
export interface TransactionFilters {
|
||||
dateFrom: Date | null;
|
||||
dateTo: Date | null;
|
||||
amountMin: number | null;
|
||||
amountMax: number | null;
|
||||
categoryIds: number[];
|
||||
accountIds: number[];
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
|
|
@ -4,6 +4,7 @@ use App\Http\Controllers\EncryptionController;
|
|||
use App\Http\Controllers\Sync\AccountSyncController;
|
||||
use App\Http\Controllers\Sync\BankSyncController;
|
||||
use App\Http\Controllers\Sync\CategorySyncController;
|
||||
use App\Http\Controllers\TransactionController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Features;
|
||||
|
|
@ -36,6 +37,10 @@ Route::middleware(['auth', 'verified', 'redirect.encryption'])->group(function (
|
|||
Route::get('dashboard', function () {
|
||||
return Inertia::render('dashboard');
|
||||
})->name('dashboard');
|
||||
|
||||
Route::get('transactions', [TransactionController::class, 'index'])->name('transactions.index');
|
||||
Route::patch('transactions/{transaction}', [TransactionController::class, 'update'])->name('transactions.update');
|
||||
Route::delete('transactions/{transaction}', [TransactionController::class, 'destroy'])->name('transactions.destroy');
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
|
||||
test('guests cannot access transactions page', function () {
|
||||
$response = $this->get(route('transactions.index'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('authenticated users can access transactions page', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
|
||||
|
||||
$response = actingAs($user)->get(route('transactions.index'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('transactions/index')
|
||||
->has('categories')
|
||||
->has('accounts')
|
||||
->has('banks')
|
||||
);
|
||||
});
|
||||
|
||||
test('users can update their own transaction category', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => null,
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction->id,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('users can update transaction notes', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
|
||||
'notes' => 'encrypted_notes_content',
|
||||
'notes_iv' => str_repeat('c', 16),
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction->id,
|
||||
'notes' => 'encrypted_notes_content',
|
||||
'notes_iv' => str_repeat('c', 16),
|
||||
]);
|
||||
});
|
||||
|
||||
test('users can clear transaction category', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
|
||||
'category_id' => null,
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction->id,
|
||||
'category_id' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('users cannot update other users transactions', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
|
||||
$otherUser = User::factory()->create(['encryption_salt' => str_repeat('b', 24)]);
|
||||
$account = Account::factory()->create(['user_id' => $otherUser->id]);
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $otherUser->id,
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('category_id must exist when updating transaction', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
|
||||
'category_id' => 99999,
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['category_id']);
|
||||
});
|
||||
|
||||
test('notes_iv must be exactly 16 characters', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
|
||||
'notes' => 'encrypted_notes',
|
||||
'notes_iv' => 'invalid',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['notes_iv']);
|
||||
});
|
||||
|
||||
test('users can soft delete their own transactions', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->deleteJson(route('transactions.destroy', $transaction));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$this->assertSoftDeleted('transactions', [
|
||||
'id' => $transaction->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('users cannot delete other users transactions', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
|
||||
$otherUser = User::factory()->create(['encryption_salt' => str_repeat('b', 24)]);
|
||||
$account = Account::factory()->create(['user_id' => $otherUser->id]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $otherUser->id,
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->deleteJson(route('transactions.destroy', $transaction));
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction->id,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('transactions index page passes user categories', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
|
||||
$otherUser = User::factory()->create();
|
||||
|
||||
$userCategory = Category::factory()->create(['user_id' => $user->id, 'name' => 'My Category']);
|
||||
Category::factory()->create(['user_id' => $otherUser->id, 'name' => 'Other Category']);
|
||||
|
||||
$response = actingAs($user)->get(route('transactions.index'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('transactions/index')
|
||||
->has('categories', 1)
|
||||
->where('categories.0.name', 'My Category')
|
||||
);
|
||||
});
|
||||
|
||||
test('transactions index page passes user accounts', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
|
||||
$otherUser = User::factory()->create();
|
||||
|
||||
Account::factory()->create(['user_id' => $user->id, 'name' => 'encrypted_name_1', 'name_iv' => str_repeat('a', 16)]);
|
||||
Account::factory()->create(['user_id' => $otherUser->id, 'name' => 'encrypted_name_2', 'name_iv' => str_repeat('b', 16)]);
|
||||
|
||||
$response = actingAs($user)->get(route('transactions.index'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('transactions/index')
|
||||
->has('accounts', 1)
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue