From b469423d813509838584d08bd8da790966eb007e Mon Sep 17 00:00:00 2001 From: myrmidex Date: Thu, 10 Jul 2025 18:04:58 +0200 Subject: [PATCH] Basic display --- .../Transactions/PurchaseController.php | 12 +- resources/css/app.css | 3 + .../js/components/Display/LedCounter.tsx | 298 ++++++++++++++++++ .../Display/MilestoneProgressBar.tsx | 167 ++++++++++ .../js/components/Display/PurchaseModal.tsx | 33 ++ .../js/components/Display/StatsPanel.tsx | 193 ++++++++++++ .../Transactions/AddPurchaseForm.tsx | 38 ++- resources/js/pages/dashboard.tsx | 159 ++++++++-- resources/views/app.blade.php | 2 +- routes/web.php | 2 +- 10 files changed, 860 insertions(+), 47 deletions(-) create mode 100644 resources/js/components/Display/LedCounter.tsx create mode 100644 resources/js/components/Display/MilestoneProgressBar.tsx create mode 100644 resources/js/components/Display/PurchaseModal.tsx create mode 100644 resources/js/components/Display/StatsPanel.tsx diff --git a/app/Http/Controllers/Transactions/PurchaseController.php b/app/Http/Controllers/Transactions/PurchaseController.php index 48925a4..b0bd1a1 100644 --- a/app/Http/Controllers/Transactions/PurchaseController.php +++ b/app/Http/Controllers/Transactions/PurchaseController.php @@ -17,7 +17,7 @@ public function index(): JsonResponse return response()->json($purchases); } - public function store(Request $request): \Illuminate\Http\RedirectResponse + public function store(Request $request): JsonResponse { $validated = $request->validate([ 'date' => 'required|date|before_or_equal:today', @@ -41,7 +41,10 @@ public function store(Request $request): \Illuminate\Http\RedirectResponse 'total_cost' => $validated['total_cost'], ]); - return Redirect::back()->with('success', 'Purchase added successfully!'); + return response()->json([ + 'success' => true, + 'message' => 'Purchase added successfully!', + ]); } public function summary() @@ -64,6 +67,9 @@ public function destroy(Purchase $purchase) { $purchase->delete(); - return Redirect::back()->with('success', 'Purchase deleted successfully!'); + return response()->json([ + 'success' => true, + 'message' => 'Purchase deleted successfully!', + ]); } } diff --git a/resources/css/app.css b/resources/css/app.css index 43fdb4a..2b360a8 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -10,6 +10,9 @@ @theme { --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + + --font-mono-display: + 'Major Mono Display', ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; --radius-lg: var(--radius); --radius-md: calc(var(--radius) - 2px); diff --git a/resources/js/components/Display/LedCounter.tsx b/resources/js/components/Display/LedCounter.tsx new file mode 100644 index 0000000..85ed4b3 --- /dev/null +++ b/resources/js/components/Display/LedCounter.tsx @@ -0,0 +1,298 @@ +import { cn } from '@/lib/utils'; +import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +interface LedCounterProps { + value: number; + className?: string; + animate?: boolean; + currentPrice?: number; + onHover?: (isHovered: boolean) => void; + // Progress bar props + onStatsToggle?: () => void; + showStats?: boolean; + onAddPurchase?: () => void; +} + +export default function LedCounter({ + value, + className, + animate = true, + currentPrice, + onHover, + onStatsToggle, + showStats = false, + onAddPurchase +}: LedCounterProps) { + const [displayValue, setDisplayValue] = useState(0); + const [isHovered, setIsHovered] = useState(false); + const [hoverTimeout, setHoverTimeout] = useState(null); + const [currentMilestoneIndex, setCurrentMilestoneIndex] = useState(0); + + // Milestone definitions + const milestones = [ + { target: 1500, label: '1.5K', color: 'bg-blue-500' }, + { target: 3000, label: '3K', color: 'bg-green-500' }, + { target: 4500, label: '4.5K', color: 'bg-yellow-500' }, + { target: 6000, label: '6K', color: 'bg-red-500' }, + ]; + + const currentMilestone = milestones[currentMilestoneIndex]; + const progress = Math.min((value / currentMilestone.target) * 100, 100); + const isCompleted = value >= currentMilestone.target; + + // Milestone navigation + const nextMilestone = () => { + setCurrentMilestoneIndex((prev) => + prev < milestones.length - 1 ? prev + 1 : 0 + ); + }; + + const prevMilestone = () => { + setCurrentMilestoneIndex((prev) => + prev > 0 ? prev - 1 : milestones.length - 1 + ); + }; + + const handleProgressBarClick = () => { + if (onStatsToggle) { + onStatsToggle(); + } + }; + + // Animate number changes + useEffect(() => { + if (!animate) { + setDisplayValue(value); + return; + } + + const duration = 1000; // 1 second animation + const steps = 60; // 60fps + const stepValue = (value - displayValue) / steps; + + if (Math.abs(stepValue) < 0.01) { + setDisplayValue(value); + return; + } + + const timer = setInterval(() => { + setDisplayValue(prev => { + const next = prev + stepValue; + if (Math.abs(next - value) < Math.abs(stepValue)) { + clearInterval(timer); + return value; + } + return next; + }); + }, duration / steps); + + return () => clearInterval(timer); + }, [value, displayValue, animate]); + + // Format number appropriately for shares + const formatValue = (value: number) => { + // If it's a whole number, show it as integer + if (value % 1 === 0) { + return value.toString(); + } + // Otherwise show up to 6 decimal places, removing trailing zeros + return value.toFixed(6).replace(/\.?0+$/, ''); + }; + + const formattedValue = formatValue(displayValue); + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 4, + }).format(amount); + }; + + return ( +
{ + if (hoverTimeout) { + clearTimeout(hoverTimeout); + setHoverTimeout(null); + } + setIsHovered(true); + if (onHover) onHover(true); + }} + onMouseLeave={() => { + // Delay hiding to allow moving to progress bar + const timeout = setTimeout(() => { + setIsHovered(false); + if (onHover) onHover(false); + }, 300); // 300ms delay + setHoverTimeout(timeout); + }} + > +
+ {/* Background glow effect */} +
+ {formattedValue} +
+ + {/* Main LED text */} +
+ {formattedValue} +
+ + {/* Subtle scan line effect */} +
+
+
+
+ + {/* Label */} +
+ total shares +
+ + {/* Hover overlay with price and add button */} +
+ {/* Current Price Display */} + {currentPrice ? ( +
+
+ current price +
+
+ {formatCurrency(currentPrice)} +
+
+ ) : ( +
+ no price data +
+ )} +
+ + {/* Progress Bar - shows when hovered */} +
+
+ {/* Progress Bar */} +
+ {/* Background pulse for completed milestones */} + {isCompleted && ( +
+ )} + + {/* Progress fill */} +
+ + {/* Glow effect */} +
+
+ + {/* Milestone Info */} +
+ {/* Left: Previous milestone button */} + + + {/* Center: Milestone info */} +
+
+ {value.toFixed(2)} / {currentMilestone.target} +
+ +
+ {currentMilestone.label} +
+ +
+ {isCompleted ? 'COMPLETED' : `${(100 - progress).toFixed(1)}% TO GO`} +
+
+ + {/* Right: Add Purchase, Next milestone button and stats toggle */} +
+ {/* Add Purchase Button */} + {onAddPurchase && ( + + )} + + + + {/* Stats indicator */} +
+ {showStats ? '▲' : '▼'} +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/resources/js/components/Display/MilestoneProgressBar.tsx b/resources/js/components/Display/MilestoneProgressBar.tsx new file mode 100644 index 0000000..36bbd64 --- /dev/null +++ b/resources/js/components/Display/MilestoneProgressBar.tsx @@ -0,0 +1,167 @@ +import { cn } from '@/lib/utils'; +import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'; +import { useState } from 'react'; + +interface Milestone { + target: number; + label: string; + color: string; +} + +interface MilestoneProgressBarProps { + currentShares: number; + className?: string; + onStatsToggle?: () => void; + showStats?: boolean; + isVisible?: boolean; + onAddPurchase?: () => void; + onHover?: (isHovered: boolean) => void; +} + +const milestones: Milestone[] = [ + { target: 1500, label: '1.5K', color: 'bg-blue-500' }, + { target: 3000, label: '3K', color: 'bg-green-500' }, + { target: 4500, label: '4.5K', color: 'bg-yellow-500' }, + { target: 6000, label: '6K', color: 'bg-red-500' }, +]; + +export default function MilestoneProgressBar({ + currentShares, + className, + onStatsToggle, + showStats = false, + isVisible = false, + onAddPurchase, + onHover +}: MilestoneProgressBarProps) { + const [currentMilestoneIndex, setCurrentMilestoneIndex] = useState(0); + + const currentMilestone = milestones[currentMilestoneIndex]; + const progress = Math.min((currentShares / currentMilestone.target) * 100, 100); + const isCompleted = currentShares >= currentMilestone.target; + + const nextMilestone = () => { + setCurrentMilestoneIndex((prev) => + prev < milestones.length - 1 ? prev + 1 : 0 + ); + }; + + const prevMilestone = () => { + setCurrentMilestoneIndex((prev) => + prev > 0 ? prev - 1 : milestones.length - 1 + ); + }; + + const handleBarClick = () => { + if (onStatsToggle) { + onStatsToggle(); + } + }; + + return ( +
onHover?.(true)} + onMouseLeave={() => onHover?.(false)} + > +
+ {/* Progress Bar */} +
+ {/* Background pulse for completed milestones */} + {isCompleted && ( +
+ )} + + {/* Progress fill */} +
+ + {/* Glow effect */} +
+
+ + {/* Milestone Info */} +
+ {/* Left: Previous milestone button */} + + + {/* Center: Milestone info */} +
+
+ {currentShares.toFixed(2)} / {currentMilestone.target} +
+ +
+ {currentMilestone.label} +
+ +
+ {isCompleted ? 'COMPLETED' : `${(100 - progress).toFixed(1)}% TO GO`} +
+
+ + {/* Right: Add Purchase, Next milestone button and stats toggle */} +
+ {/* Add Purchase Button */} + {onAddPurchase && ( + + )} + + + + {/* Stats indicator */} +
+ {showStats ? '▲' : '▼'} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/resources/js/components/Display/PurchaseModal.tsx b/resources/js/components/Display/PurchaseModal.tsx new file mode 100644 index 0000000..683a91c --- /dev/null +++ b/resources/js/components/Display/PurchaseModal.tsx @@ -0,0 +1,33 @@ +import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; + +interface PurchaseModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess?: () => void; +} + +export default function PurchaseModal({ isOpen, onClose, onSuccess }: PurchaseModalProps) { + const handleSuccess = () => { + if (onSuccess) { + onSuccess(); + } + onClose(); + }; + + return ( + + + + + ADD PURCHASE + + + +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/resources/js/components/Display/StatsPanel.tsx b/resources/js/components/Display/StatsPanel.tsx new file mode 100644 index 0000000..00ae6f2 --- /dev/null +++ b/resources/js/components/Display/StatsPanel.tsx @@ -0,0 +1,193 @@ +import { cn } from '@/lib/utils'; +import { useState } from 'react'; + +interface StatsData { + totalShares: number; + totalInvestment: number; + averageCostPerShare: number; + currentPrice?: number; + currentValue?: number; + profitLoss?: number; + profitLossPercentage?: number; +} + +interface StatsPanelProps { + stats: StatsData; + isVisible: boolean; + className?: string; +} + +export default function StatsPanel({ stats, isVisible, className }: StatsPanelProps) { + const [withdrawalRate, setWithdrawalRate] = useState(0.03); // 3% default + + const calculateWithdrawal = (rate: number) => { + if (!stats.currentValue) return 0; + return stats.currentValue * rate; + }; + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 2, + }).format(amount); + }; + + const formatPercentage = (percentage: number) => { + return `${percentage >= 0 ? '+' : ''}${percentage.toFixed(2)}%`; + }; + + return ( +
+
+
+ + {/* Portfolio Overview */} +
+

+ Portfolio +

+ +
+
+ Shares: + {stats.totalShares.toFixed(6)} +
+ +
+ Invested: + {formatCurrency(stats.totalInvestment)} +
+ +
+ Avg Cost: + {formatCurrency(stats.averageCostPerShare)} +
+
+
+ + {/* Current Value */} + {stats.currentPrice && ( +
+

+ Current Value +

+ +
+
+ Price: + {formatCurrency(stats.currentPrice)} +
+ +
+ Value: + {formatCurrency(stats.currentValue || 0)} +
+ + {stats.profitLoss !== undefined && ( +
+ P&L: + = 0 ? "text-green-400" : "text-red-500" + )}> + {formatCurrency(stats.profitLoss)} + +
+ )} + + {stats.profitLossPercentage !== undefined && ( +
+ Return: + = 0 ? "text-green-400" : "text-red-500" + )}> + {formatPercentage(stats.profitLossPercentage)} + +
+ )} +
+
+ )} + + {/* Withdrawal Estimates */} + {stats.currentValue && ( +
+

+ Annual Withdrawal +

+ +
+
+ 3%: + {formatCurrency(calculateWithdrawal(0.03))} +
+ +
+ 4%: + {formatCurrency(calculateWithdrawal(0.04))} +
+ +
+ Custom: +
+ setWithdrawalRate(Number(e.target.value) / 100)} + className="w-12 bg-transparent text-red-400 text-right text-xs border-b border-red-500/30 focus:border-red-400 outline-none" + min="0" + max="10" + step="0.1" + /> + % +
+
+ +
+ + {formatCurrency(calculateWithdrawal(withdrawalRate))} +
+
+
+ )} + + {/* Monthly Breakdown */} + {stats.currentValue && ( +
+

+ Monthly Income +

+ +
+
+ 3%: + {formatCurrency(calculateWithdrawal(0.03) / 12)} +
+ +
+ 4%: + {formatCurrency(calculateWithdrawal(0.04) / 12)} +
+ +
+ Custom: + {formatCurrency(calculateWithdrawal(withdrawalRate) / 12)} +
+
+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/resources/js/components/Transactions/AddPurchaseForm.tsx b/resources/js/components/Transactions/AddPurchaseForm.tsx index 025b87a..edc42ba 100644 --- a/resources/js/components/Transactions/AddPurchaseForm.tsx +++ b/resources/js/components/Transactions/AddPurchaseForm.tsx @@ -12,9 +12,14 @@ interface PurchaseFormData { shares: string; price_per_share: string; total_cost: string; + [key: string]: string; } -export default function AddPurchaseForm() { +interface AddPurchaseFormProps { + onSuccess?: () => void; +} + +export default function AddPurchaseForm({ onSuccess }: AddPurchaseFormProps) { const { data, setData, post, processing, errors, reset } = useForm({ date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format shares: '', @@ -42,31 +47,32 @@ export default function AddPurchaseForm() { onSuccess: () => { reset(); setData('date', new Date().toISOString().split('T')[0]); + if (onSuccess) { + onSuccess(); + } }, }); }; return ( - - - Add VWCE Purchase - - +
+
- + setData('date', e.target.value)} max={new Date().toISOString().split('T')[0]} + className="bg-black border-red-500/30 text-red-400 focus:border-red-400" />
- + setData('shares', e.target.value)} + className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30" />
- + setData('price_per_share', e.target.value)} + className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30" />
- + setData('total_cost', e.target.value)} - className="bg-neutral-50 dark:bg-neutral-800" + className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30" /> -

+

Auto-calculated from shares × price

@@ -114,13 +122,13 @@ export default function AddPurchaseForm() { - - +
+
); } \ No newline at end of file diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 822c3dc..0871ac6 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -1,35 +1,140 @@ -import { PlaceholderPattern } from '@/components/ui/PlaceholderPattern'; -import AppLayout from '@/layouts/app-layout'; -import { type BreadcrumbItem } from '@/types'; +import LedCounter from '@/components/Display/LedCounter'; +import PurchaseModal from '@/components/Display/PurchaseModal'; +import StatsPanel from '@/components/Display/StatsPanel'; import { Head } from '@inertiajs/react'; +import { useEffect, useState } from 'react'; -const breadcrumbs: BreadcrumbItem[] = [ - { - title: 'Dashboard', - href: '/dashboard', - }, -]; +interface PurchaseSummary { + total_shares: number; + total_investment: number; + average_cost_per_share: number; +} + +interface CurrentPrice { + current_price: number | null; +} export default function Dashboard() { + const [purchaseData, setPurchaseData] = useState({ + total_shares: 0, + total_investment: 0, + average_cost_per_share: 0, + }); + + const [priceData, setPriceData] = useState({ + current_price: null, + }); + + const [showStats, setShowStats] = useState(false); + const [showPurchaseModal, setShowPurchaseModal] = useState(false); + const [loading, setLoading] = useState(true); + + // Fetch purchase summary and current price + useEffect(() => { + const fetchData = async () => { + try { + const [purchaseResponse, priceResponse] = await Promise.all([ + fetch('/purchases/summary'), + fetch('/pricing/current'), + ]); + + if (purchaseResponse.ok) { + const purchases = await purchaseResponse.json(); + setPurchaseData(purchases); + } + + if (priceResponse.ok) { + const price = await priceResponse.json(); + setPriceData(price); + } + } catch (error) { + console.error('Failed to fetch data:', error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + // Refresh data after successful purchase + const handlePurchaseSuccess = async () => { + try { + const purchaseResponse = await fetch('/purchases/summary'); + if (purchaseResponse.ok) { + const purchases = await purchaseResponse.json(); + setPurchaseData(purchases); + } + } catch (error) { + console.error('Failed to refresh purchase data:', error); + } + }; + + + // Calculate portfolio stats + const currentValue = priceData.current_price + ? purchaseData.total_shares * priceData.current_price + : undefined; + + const profitLoss = currentValue + ? currentValue - purchaseData.total_investment + : undefined; + + const profitLossPercentage = profitLoss && purchaseData.total_investment > 0 + ? (profitLoss / purchaseData.total_investment) * 100 + : undefined; + + const statsData = { + totalShares: purchaseData.total_shares, + totalInvestment: purchaseData.total_investment, + averageCostPerShare: purchaseData.average_cost_per_share, + currentPrice: priceData.current_price || undefined, + currentValue, + profitLoss, + profitLossPercentage, + }; + + if (loading) { + return ( + <> + +
+
+ LOADING... +
+
+ + ); + } + return ( - - -
-
-
- -
-
- -
-
- -
-
-
- -
+ <> + + + {/* Main LED Display */} +
+ setShowStats(!showStats)} + showStats={showStats} + onAddPurchase={() => setShowPurchaseModal(true)} + /> + + {/* Stats Panel */} + + + {/* Purchase Modal */} + setShowPurchaseModal(false)} + onSuccess={handlePurchaseSuccess} + />
- + ); } diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 8218267..018eab2 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -37,7 +37,7 @@ - + @routes @viteReactRefresh diff --git a/routes/web.php b/routes/web.php index 59d6907..ae50c44 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,7 +6,7 @@ use Inertia\Inertia; Route::get('/', function () { - return Inertia::render('welcome'); + return redirect('/dashboard'); })->name('home'); Route::get('dashboard', function () {