diff --git a/app/Http/Controllers/Transactions/PurchaseController.php b/app/Http/Controllers/Transactions/PurchaseController.php new file mode 100644 index 0000000..48925a4 --- /dev/null +++ b/app/Http/Controllers/Transactions/PurchaseController.php @@ -0,0 +1,69 @@ +get(); + + return response()->json($purchases); + } + + public function store(Request $request): \Illuminate\Http\RedirectResponse + { + $validated = $request->validate([ + 'date' => 'required|date|before_or_equal:today', + 'shares' => 'required|numeric|min:0.000001', + 'price_per_share' => 'required|numeric|min:0.01', + 'total_cost' => 'required|numeric|min:0.01', + ]); + + // Verify calculation is correct + $calculatedTotal = $validated['shares'] * $validated['price_per_share']; + if (abs($calculatedTotal - $validated['total_cost']) > 0.01) { + return back()->withErrors([ + 'total_cost' => 'Total cost does not match shares × price per share.' + ]); + } + + Purchase::create([ + 'date' => $validated['date'], + 'shares' => $validated['shares'], + 'price_per_share' => $validated['price_per_share'], + 'total_cost' => $validated['total_cost'], + ]); + + return Redirect::back()->with('success', 'Purchase added successfully!'); + } + + public function summary() + { + $totalShares = Purchase::totalShares(); + $totalInvestment = Purchase::totalInvestment(); + $averageCost = Purchase::averageCostPerShare(); + + return response()->json([ + 'total_shares' => $totalShares, + 'total_investment' => $totalInvestment, + 'average_cost_per_share' => $averageCost, + ]); + } + + /** + * Remove the specified purchase. + */ + public function destroy(Purchase $purchase) + { + $purchase->delete(); + + return Redirect::back()->with('success', 'Purchase deleted successfully!'); + } +} diff --git a/app/Models/Transactions/Purchase.php b/app/Models/Transactions/Purchase.php new file mode 100644 index 0000000..693b8ce --- /dev/null +++ b/app/Models/Transactions/Purchase.php @@ -0,0 +1,52 @@ + 'date', + 'shares' => 'decimal:6', + 'price_per_share' => 'decimal:4', + 'total_cost' => 'decimal:2', + ]; + + /** + * Calculate total shares + */ + public static function totalShares(): float + { + return static::sum('shares'); + } + + /** + * Calculate total investment + */ + public static function totalInvestment(): float + { + return static::sum('total_cost'); + } + + /** + * Get average cost per share + */ + public static function averageCostPerShare(): float + { + $totalShares = static::totalShares(); + $totalCost = static::totalInvestment(); + + return $totalShares > 0 ? $totalCost / $totalShares : 0; + } +} \ No newline at end of file diff --git a/database/migrations/2025_07_10_150549_create_purchases_table.php b/database/migrations/2025_07_10_150549_create_purchases_table.php new file mode 100644 index 0000000..edfc344 --- /dev/null +++ b/database/migrations/2025_07_10_150549_create_purchases_table.php @@ -0,0 +1,33 @@ +id(); + $table->date('date'); + $table->decimal('shares', 12, 6); // Supports fractional shares + $table->decimal('price_per_share', 8, 4); // Price in euros + $table->decimal('total_cost', 12, 2); // Total cost in euros + $table->timestamps(); + + $table->index('date'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('purchases'); + } +}; diff --git a/resources/js/components/Transactions/AddPurchaseForm.tsx b/resources/js/components/Transactions/AddPurchaseForm.tsx new file mode 100644 index 0000000..025b87a --- /dev/null +++ b/resources/js/components/Transactions/AddPurchaseForm.tsx @@ -0,0 +1,126 @@ +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import InputError from '@/components/InputError'; +import { useForm } from '@inertiajs/react'; +import { LoaderCircle } from 'lucide-react'; +import { FormEventHandler, useEffect } from 'react'; + +interface PurchaseFormData { + date: string; + shares: string; + price_per_share: string; + total_cost: string; +} + +export default function AddPurchaseForm() { + const { data, setData, post, processing, errors, reset } = useForm({ + date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format + shares: '', + price_per_share: '', + total_cost: '', + }); + + // Auto-calculate total cost when shares or price changes + useEffect(() => { + if (data.shares && data.price_per_share) { + const shares = parseFloat(data.shares); + const pricePerShare = parseFloat(data.price_per_share); + + if (!isNaN(shares) && !isNaN(pricePerShare)) { + const totalCost = (shares * pricePerShare).toFixed(2); + setData('total_cost', totalCost); + } + } + }, [data.shares, data.price_per_share, setData]); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + + post(route('purchases.store'), { + onSuccess: () => { + reset(); + setData('date', new Date().toISOString().split('T')[0]); + }, + }); + }; + + return ( + + + Add VWCE Purchase + + +
+
+ + setData('date', e.target.value)} + max={new Date().toISOString().split('T')[0]} + /> + +
+ +
+ + setData('shares', e.target.value)} + /> + +
+ +
+ + setData('price_per_share', e.target.value)} + /> + +
+ +
+ + setData('total_cost', e.target.value)} + className="bg-neutral-50 dark:bg-neutral-800" + /> +

+ Auto-calculated from shares × price +

+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 5e4cebd..2743eaa 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ name('home'); -Route::middleware(['auth', 'verified'])->group(function () { - Route::get('dashboard', function () { - return Inertia::render('dashboard'); - })->name('dashboard'); +Route::get('dashboard', function () { + return Inertia::render('dashboard'); +})->name('dashboard'); + +// Purchase routes +Route::prefix('purchases')->name('purchases.')->group(function () { + Route::get('/', [PurchaseController::class, 'index'])->name('index'); + Route::post('/', [PurchaseController::class, 'store'])->name('store'); + Route::get('/summary', [PurchaseController::class, 'summary'])->name('summary'); + Route::delete('/{purchase}', [PurchaseController::class, 'destroy'])->name('destroy'); }); require __DIR__.'/settings.php';