Merge pull request 'Re-arrange components' (#12) from 9-rearrange-components into releases/v0.1
Reviewed-on: https://codeberg.org/lvl0/incr/pulls/12
This commit is contained in:
commit
6435900079
5 changed files with 447 additions and 41 deletions
68
resources/js/components/Display/InlineForm.tsx
Normal file
68
resources/js/components/Display/InlineForm.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
||||||
|
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface InlineFormProps {
|
||||||
|
type: 'purchase' | 'milestone' | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onPurchaseSuccess?: () => void;
|
||||||
|
onMilestoneSuccess?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InlineForm({
|
||||||
|
type,
|
||||||
|
onClose,
|
||||||
|
onPurchaseSuccess,
|
||||||
|
onMilestoneSuccess,
|
||||||
|
className
|
||||||
|
}: InlineFormProps) {
|
||||||
|
if (!type) return null;
|
||||||
|
|
||||||
|
const title = type === 'purchase' ? 'ADD PURCHASE' : 'ADD MILESTONE';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-black border-4 border-gray-800 rounded-lg",
|
||||||
|
"shadow-2xl shadow-red-500/20",
|
||||||
|
"p-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-red-500 font-mono tracking-wide text-lg">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-red-400 hover:text-red-300 transition-colors p-1"
|
||||||
|
aria-label="Close form"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Content */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{type === 'purchase' ? (
|
||||||
|
<AddPurchaseForm
|
||||||
|
onSuccess={() => {
|
||||||
|
if (onPurchaseSuccess) onPurchaseSuccess();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AddMilestoneForm
|
||||||
|
onSuccess={() => {
|
||||||
|
if (onMilestoneSuccess) onMilestoneSuccess();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
resources/js/components/Display/LedDisplay.tsx
Normal file
104
resources/js/components/Display/LedDisplay.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface LedDisplayProps {
|
||||||
|
value: number;
|
||||||
|
className?: string;
|
||||||
|
animate?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LedDisplay({
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
animate = true,
|
||||||
|
onClick
|
||||||
|
}: LedDisplayProps) {
|
||||||
|
const [displayValue, setDisplayValue] = useState(0);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-center select-none cursor-pointer",
|
||||||
|
"bg-black text-red-500",
|
||||||
|
"border-4 border-gray-800 rounded-lg",
|
||||||
|
"shadow-2xl shadow-red-500/20",
|
||||||
|
"p-8 transition-all duration-300",
|
||||||
|
"hover:shadow-red-500/40 hover:border-red-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{/* Background glow effect */}
|
||||||
|
<div className="absolute inset-0 text-red-500/20 blur-sm font-mono-display text-6xl md:text-8xl lg:text-9xl tracking-widest">
|
||||||
|
{formattedValue}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main LED text */}
|
||||||
|
<div className={cn(
|
||||||
|
"relative z-10",
|
||||||
|
"text-6xl md:text-8xl lg:text-9xl",
|
||||||
|
"font-mono-display font-normal tracking-widest",
|
||||||
|
"text-red-500",
|
||||||
|
"drop-shadow-[0_0_10px_rgba(239,68,68,0.8)]",
|
||||||
|
"filter brightness-110"
|
||||||
|
)}>
|
||||||
|
{formattedValue}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subtle scan line effect */}
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
<div className="h-full w-full bg-gradient-to-b from-transparent via-red-500/5 to-transparent animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<div className="mt-4 text-red-400/80 text-sm md:text-base font-mono-display tracking-wider">
|
||||||
|
total shares
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
resources/js/components/Display/ProgressBar.tsx
Normal file
60
resources/js/components/Display/ProgressBar.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
value: number;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProgressBar({
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
onClick
|
||||||
|
}: ProgressBarProps) {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-black border-4 border-gray-800 rounded-lg",
|
||||||
|
"shadow-2xl shadow-red-500/20 cursor-pointer",
|
||||||
|
"transition-all duration-300",
|
||||||
|
"hover:shadow-red-500/40 hover:border-red-600",
|
||||||
|
"p-8 text-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="text-red-500 text-2xl font-mono tracking-wide">
|
||||||
|
PROGRESS BAR
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
resources/js/components/Display/StatsBox.tsx
Normal file
156
resources/js/components/Display/StatsBox.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
interface StatsBoxProps {
|
||||||
|
stats: {
|
||||||
|
totalShares: number;
|
||||||
|
totalInvestment: number;
|
||||||
|
averageCostPerShare: number;
|
||||||
|
currentPrice?: number;
|
||||||
|
currentValue?: number;
|
||||||
|
profitLoss?: number;
|
||||||
|
profitLossPercentage?: number;
|
||||||
|
};
|
||||||
|
className?: string;
|
||||||
|
onAddPurchase?: () => void;
|
||||||
|
onAddMilestone?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatsBox({
|
||||||
|
stats,
|
||||||
|
className,
|
||||||
|
onAddPurchase,
|
||||||
|
onAddMilestone
|
||||||
|
}: StatsBoxProps) {
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrencyDetailed = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
minimumFractionDigits: 4,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-black border-4 border-gray-800 rounded-lg",
|
||||||
|
"shadow-2xl shadow-red-500/20",
|
||||||
|
"p-6 space-y-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Current Price */}
|
||||||
|
{stats.currentPrice && (
|
||||||
|
<div className="text-center border-b border-red-500/30 pb-4">
|
||||||
|
<div className="text-red-400/70 text-sm font-medium tracking-wide mb-2">
|
||||||
|
current price
|
||||||
|
</div>
|
||||||
|
<div className="text-red-500 text-2xl md:text-3xl font-mono-display tracking-wider">
|
||||||
|
{formatCurrencyDetailed(stats.currentPrice)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Portfolio Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm font-mono">
|
||||||
|
{/* Total Investment */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-red-400/70 text-xs">Total Investment</div>
|
||||||
|
<div className="text-red-400">
|
||||||
|
{formatCurrency(stats.totalInvestment)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Value */}
|
||||||
|
{stats.currentValue && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-red-400/70 text-xs">Current Value</div>
|
||||||
|
<div className="text-red-400">
|
||||||
|
{formatCurrency(stats.currentValue)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Average Cost */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-red-400/70 text-xs">Avg Cost/Share</div>
|
||||||
|
<div className="text-red-400">
|
||||||
|
{formatCurrencyDetailed(stats.averageCostPerShare)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profit/Loss */}
|
||||||
|
{stats.profitLoss !== undefined && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-red-400/70 text-xs">P&L</div>
|
||||||
|
<div className={cn(
|
||||||
|
"font-bold",
|
||||||
|
stats.profitLoss >= 0 ? "text-green-400" : "text-red-400"
|
||||||
|
)}>
|
||||||
|
{stats.profitLoss >= 0 ? '+' : ''}{formatCurrency(stats.profitLoss)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Withdrawal Estimates */}
|
||||||
|
{stats.currentValue && (
|
||||||
|
<div className="border-t border-red-500/30 pt-4">
|
||||||
|
<div className="text-red-400/70 text-xs mb-2">Annual Withdrawal (Safe)</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm font-mono">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-red-400/60 text-xs">3% Rule</div>
|
||||||
|
<div className="text-red-400">
|
||||||
|
{formatCurrency(stats.currentValue * 0.03)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-red-400/60 text-xs">4% Rule</div>
|
||||||
|
<div className="text-red-400">
|
||||||
|
{formatCurrency(stats.currentValue * 0.04)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="border-t border-red-500/30 pt-4">
|
||||||
|
<div className="flex items-center justify-center space-x-4">
|
||||||
|
{/* Add Purchase Button */}
|
||||||
|
{onAddPurchase && (
|
||||||
|
<button
|
||||||
|
onClick={onAddPurchase}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 rounded bg-red-600/20 border border-red-500/50 text-red-400 hover:bg-red-600/40 hover:text-red-300 transition-colors text-sm"
|
||||||
|
aria-label="Add purchase"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span className="font-mono">ADD PURCHASE</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Milestone Button */}
|
||||||
|
{onAddMilestone && (
|
||||||
|
<button
|
||||||
|
onClick={onAddMilestone}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 rounded bg-blue-600/20 border border-blue-500/50 text-blue-400 hover:bg-blue-600/40 hover:text-blue-300 transition-colors text-sm"
|
||||||
|
aria-label="Add milestone"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span className="font-mono">ADD MILESTONE</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import LedCounter from '@/components/Display/LedCounter';
|
import LedDisplay from '@/components/Display/LedDisplay';
|
||||||
import MilestoneModal from '@/components/Display/MilestoneModal';
|
import InlineForm from '@/components/Display/InlineForm';
|
||||||
import PurchaseModal from '@/components/Display/PurchaseModal';
|
import ProgressBar from '@/components/Display/ProgressBar';
|
||||||
import StatsPanel from '@/components/Display/StatsPanel';
|
import StatsBox from '@/components/Display/StatsBox';
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -26,9 +26,9 @@ export default function Dashboard() {
|
||||||
current_price: null,
|
current_price: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [showStats, setShowStats] = useState(false);
|
const [showProgressBar, setShowProgressBar] = useState(false);
|
||||||
const [showPurchaseModal, setShowPurchaseModal] = useState(false);
|
const [showStatsBox, setShowStatsBox] = useState(false);
|
||||||
const [showMilestoneModal, setShowMilestoneModal] = useState(false);
|
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Fetch purchase summary and current price
|
// Fetch purchase summary and current price
|
||||||
|
|
@ -109,44 +109,62 @@ export default function Dashboard() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle handlers with cascading behavior
|
||||||
|
const handleLedClick = () => {
|
||||||
|
const newShowProgressBar = !showProgressBar;
|
||||||
|
setShowProgressBar(newShowProgressBar);
|
||||||
|
if (!newShowProgressBar) {
|
||||||
|
// If hiding progress bar, also hide stats box
|
||||||
|
setShowStatsBox(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgressClick = () => {
|
||||||
|
setShowStatsBox(!showStatsBox);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="VWCE Tracker" />
|
<Head title="VWCE Tracker" />
|
||||||
|
|
||||||
{/* Main LED Display */}
|
{/* Stacked Layout */}
|
||||||
<div className="min-h-screen bg-black flex items-center justify-center relative">
|
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||||
<LedCounter
|
<div className="w-full max-w-4xl mx-4 space-y-4">
|
||||||
value={purchaseData.total_shares}
|
{/* Box 1: LED Number Display */}
|
||||||
className="max-w-4xl w-full mx-4"
|
<LedDisplay
|
||||||
currentPrice={priceData.current_price || undefined}
|
value={purchaseData.total_shares}
|
||||||
onStatsToggle={() => setShowStats(!showStats)}
|
onClick={handleLedClick}
|
||||||
showStats={showStats}
|
/>
|
||||||
onAddPurchase={() => setShowPurchaseModal(true)}
|
|
||||||
onAddMilestone={() => setShowMilestoneModal(true)}
|
{/* Box 2: Progress Bar (toggleable) */}
|
||||||
/>
|
<div style={{ display: showProgressBar ? 'block' : 'none' }}>
|
||||||
|
<ProgressBar
|
||||||
{/* Stats Panel */}
|
value={purchaseData.total_shares}
|
||||||
<StatsPanel
|
onClick={handleProgressClick}
|
||||||
stats={statsData}
|
/>
|
||||||
isVisible={showStats}
|
</div>
|
||||||
/>
|
|
||||||
|
{/* Box 3: Stats Box (toggleable) */}
|
||||||
{/* Purchase Modal */}
|
<div style={{ display: showStatsBox ? 'block' : 'none' }}>
|
||||||
<PurchaseModal
|
<StatsBox
|
||||||
isOpen={showPurchaseModal}
|
stats={statsData}
|
||||||
onClose={() => setShowPurchaseModal(false)}
|
onAddPurchase={() => setActiveForm('purchase')}
|
||||||
onSuccess={handlePurchaseSuccess}
|
onAddMilestone={() => setActiveForm('milestone')}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{/* Milestone Modal */}
|
|
||||||
<MilestoneModal
|
{/* Box 4: Forms (only when active form is set) */}
|
||||||
isOpen={showMilestoneModal}
|
<div style={{ display: activeForm ? 'block' : 'none' }}>
|
||||||
onClose={() => setShowMilestoneModal(false)}
|
<InlineForm
|
||||||
onSuccess={() => {
|
type={activeForm}
|
||||||
// Could refresh milestone data here if needed
|
onClose={() => setActiveForm(null)}
|
||||||
console.log('Milestone added successfully');
|
onPurchaseSuccess={handlePurchaseSuccess}
|
||||||
}}
|
onMilestoneSuccess={() => {
|
||||||
/>
|
console.log('Milestone added successfully');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue