diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php new file mode 100644 index 00000000..30ecca5f --- /dev/null +++ b/app/Http/Controllers/TransactionController.php @@ -0,0 +1,71 @@ +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', + ]); + } +} diff --git a/app/Http/Requests/UpdateTransactionRequest.php b/app/Http/Requests/UpdateTransactionRequest.php new file mode 100644 index 00000000..4b316e92 --- /dev/null +++ b/app/Http/Requests/UpdateTransactionRequest.php @@ -0,0 +1,30 @@ + ['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.', + ]; + } +} diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 09abc389..a2e8a366 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -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', diff --git a/app/Policies/TransactionPolicy.php b/app/Policies/TransactionPolicy.php new file mode 100644 index 00000000..d766b970 --- /dev/null +++ b/app/Policies/TransactionPolicy.php @@ -0,0 +1,44 @@ +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; + } +} diff --git a/database/migrations/2025_11_08_144530_add_soft_deletes_to_transactions_table.php b/database/migrations/2025_11_08_144530_add_soft_deletes_to_transactions_table.php new file mode 100644 index 00000000..52cc4588 --- /dev/null +++ b/database/migrations/2025_11_08_144530_add_soft_deletes_to_transactions_table.php @@ -0,0 +1,28 @@ +softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/resources/js/components/app-header.tsx b/resources/js/components/app-header.tsx index 1f637192..36aba136 100644 --- a/resources/js/components/app-header.tsx +++ b/resources/js/components/app-header.tsx @@ -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[] = [ diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index a7930ea2..6f510ded 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -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[] = [ diff --git a/resources/js/components/encrypted-text.tsx b/resources/js/components/encrypted-text.tsx index 72d6a380..bbc3139b 100644 --- a/resources/js/components/encrypted-text.tsx +++ b/resources/js/components/encrypted-text.tsx @@ -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(null); - const [isMounted, setIsMounted] = useState(false); - const [isAnimating, setIsAnimating] = useState(false); - const isFirstLoad = useRef(true); - const fallbackCharsRef = useRef(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(() => { + 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(maskedText); + const intervalRef = useRef(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 ; - } - - return ( - - {outputText} - {remainder} - - ); + return {displayValue}; } - diff --git a/resources/js/components/transactions/category-cell.tsx b/resources/js/components/transactions/category-cell.tsx new file mode 100644 index 00000000..821adac5 --- /dev/null +++ b/resources/js/components/transactions/category-cell.tsx @@ -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 ( + + ); +} + diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx new file mode 100644 index 00000000..fc343940 --- /dev/null +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -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('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 ( + + + + Edit Transaction + + Update the category and notes for this transaction. + + + +
+
+
+ +
+ {format(parseISO(transaction.transaction_date), 'PPP')} +
+
+ +
+ +
+ {transaction.decryptedDescription} +
+
+ +
+ +
+ {new Intl.NumberFormat('en-US', { + style: 'currency', + currency: transaction.currency_code, + }).format(parseFloat(transaction.amount))} +
+
+ +
+ + +
+ +
+ +