Move buttons into dropdown
This commit is contained in:
parent
4e0ae175fb
commit
6c1f4a14c3
5 changed files with 163 additions and 144 deletions
|
|
@ -18,7 +18,7 @@ public function current(): JsonResponse
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request): JsonResponse
|
public function update(Request $request)
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'date' => 'required|date|before_or_equal:today',
|
'date' => 'required|date|before_or_equal:today',
|
||||||
|
|
@ -27,11 +27,7 @@ public function update(Request $request): JsonResponse
|
||||||
|
|
||||||
$assetPrice = AssetPrice::updatePrice($validated['date'], $validated['price']);
|
$assetPrice = AssetPrice::updatePrice($validated['date'], $validated['price']);
|
||||||
|
|
||||||
return response()->json([
|
return back()->with('success', 'Asset price updated successfully!');
|
||||||
'success' => true,
|
|
||||||
'message' => 'Asset price updated successfully!',
|
|
||||||
'data' => $assetPrice,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function history(Request $request): JsonResponse
|
public function history(Request $request): JsonResponse
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
||||||
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
|
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
|
||||||
|
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
interface InlineFormProps {
|
interface InlineFormProps {
|
||||||
type: 'purchase' | 'milestone' | null;
|
type: 'purchase' | 'milestone' | 'price' | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPurchaseSuccess?: () => void;
|
onPurchaseSuccess?: () => void;
|
||||||
onMilestoneSuccess?: () => void;
|
onMilestoneSuccess?: () => void;
|
||||||
|
onPriceSuccess?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -16,11 +18,12 @@ export default function InlineForm({
|
||||||
onClose,
|
onClose,
|
||||||
onPurchaseSuccess,
|
onPurchaseSuccess,
|
||||||
onMilestoneSuccess,
|
onMilestoneSuccess,
|
||||||
|
onPriceSuccess,
|
||||||
className
|
className
|
||||||
}: InlineFormProps) {
|
}: InlineFormProps) {
|
||||||
if (!type) return null;
|
if (!type) return null;
|
||||||
|
|
||||||
const title = type === 'purchase' ? 'ADD PURCHASE' : 'ADD MILESTONE';
|
const title = type === 'purchase' ? 'ADD PURCHASE' : type === 'milestone' ? 'ADD MILESTONE' : 'UPDATE PRICE';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -54,13 +57,20 @@ export default function InlineForm({
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : type === 'milestone' ? (
|
||||||
<AddMilestoneForm
|
<AddMilestoneForm
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
if (onMilestoneSuccess) onMilestoneSuccess();
|
if (onMilestoneSuccess) onMilestoneSuccess();
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<UpdatePriceForm
|
||||||
|
onSuccess={() => {
|
||||||
|
if (onPriceSuccess) onPriceSuccess();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface Milestone {
|
interface Milestone {
|
||||||
target: number;
|
target: number;
|
||||||
|
|
@ -21,6 +22,7 @@ interface StatsBoxProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
onAddPurchase?: () => void;
|
onAddPurchase?: () => void;
|
||||||
onAddMilestone?: () => void;
|
onAddMilestone?: () => void;
|
||||||
|
onUpdatePrice?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatsBox({
|
export default function StatsBox({
|
||||||
|
|
@ -28,8 +30,10 @@ export default function StatsBox({
|
||||||
milestones = [],
|
milestones = [],
|
||||||
className,
|
className,
|
||||||
onAddPurchase,
|
onAddPurchase,
|
||||||
onAddMilestone
|
onAddMilestone,
|
||||||
|
onUpdatePrice
|
||||||
}: StatsBoxProps) {
|
}: StatsBoxProps) {
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat('de-DE', {
|
return new Intl.NumberFormat('de-DE', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
|
|
@ -50,149 +54,141 @@ export default function StatsBox({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-black border-4 border-gray-800 rounded-lg",
|
"bg-black p-8",
|
||||||
"shadow-2xl shadow-red-500/20",
|
"transition-all duration-300",
|
||||||
"p-6 space-y-4",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Current Price */}
|
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4">
|
||||||
{stats.currentPrice && (
|
{/* STATS Title and Current Price */}
|
||||||
<div className="text-center border-b border-red-500/30 pb-4">
|
<div className="flex justify-between items-center mb-6 relative">
|
||||||
<div className="text-red-400/70 text-sm font-medium tracking-wide mb-2">
|
<h2 className="text-red-500 text-lg font-mono font-bold tracking-wider">
|
||||||
current price
|
STATS
|
||||||
</div>
|
</h2>
|
||||||
<div className="text-red-500 text-2xl md:text-3xl font-mono-display tracking-wider">
|
<div className="flex items-center space-x-4 relative">
|
||||||
{formatCurrencyDetailed(stats.currentPrice)}
|
{stats.currentPrice && (
|
||||||
</div>
|
<div className="text-red-500 text-sm font-mono tracking-wider">
|
||||||
</div>
|
VWCE: {formatCurrencyDetailed(stats.currentPrice)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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>
|
)}
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-red-400/60 text-xs">4% Rule</div>
|
{/* Action Dropdown */}
|
||||||
<div className="text-red-400">
|
<div className="relative">
|
||||||
{formatCurrency(stats.currentValue * 0.04)}
|
<button
|
||||||
</div>
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
</div>
|
className="flex items-center justify-center px-2 py-1 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"
|
||||||
</div>
|
aria-label="Add actions"
|
||||||
</div>
|
>
|
||||||
)}
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
{/* Milestones */}
|
|
||||||
{milestones.length > 0 && (
|
{/* Dropdown Menu */}
|
||||||
<div className="border-t border-red-500/30 pt-4">
|
{isDropdownOpen && (
|
||||||
<div className="text-red-400/70 text-xs mb-3">Milestones</div>
|
<div className="absolute top-full right-0 mt-2 bg-black border-2 border-red-500/50 rounded shadow-lg min-w-40 z-10">
|
||||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
{onAddPurchase && (
|
||||||
{milestones.map((milestone, index) => {
|
<button
|
||||||
const isReached = stats.totalShares >= milestone.target;
|
onClick={() => {
|
||||||
return (
|
onAddPurchase();
|
||||||
<div key={index} className="flex items-center justify-between text-sm">
|
setIsDropdownOpen(false);
|
||||||
<div className="flex items-center space-x-2">
|
}}
|
||||||
<div className={cn(
|
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
||||||
"w-2 h-2 rounded-full",
|
>
|
||||||
isReached ? "bg-green-400" : "bg-gray-600"
|
ADD PURCHASE
|
||||||
)} />
|
</button>
|
||||||
<span className={cn(
|
)}
|
||||||
"font-mono",
|
{onAddMilestone && (
|
||||||
isReached ? "text-green-400" : "text-red-400"
|
<button
|
||||||
)}>
|
onClick={() => {
|
||||||
{milestone.target.toLocaleString()}
|
onAddMilestone();
|
||||||
</span>
|
setIsDropdownOpen(false);
|
||||||
</div>
|
}}
|
||||||
<div className={cn(
|
className="w-full text-left px-4 py-2 text-blue-400 hover:bg-blue-600/20 hover:text-blue-300 transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
||||||
"text-xs truncate ml-2",
|
>
|
||||||
isReached ? "text-green-400/70" : "text-red-400/70"
|
ADD MILESTONE
|
||||||
)}>
|
</button>
|
||||||
{milestone.description}
|
)}
|
||||||
</div>
|
{onUpdatePrice && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onUpdatePrice();
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-green-400 hover:bg-green-600/20 hover:text-green-300 transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
||||||
|
>
|
||||||
|
UPDATE PRICE
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Milestone Table */}
|
||||||
<div className="border-t border-red-500/30 pt-4">
|
<div className="border-t border-red-500/30 pt-4">
|
||||||
<div className="flex items-center justify-center space-x-4">
|
<div className="text-red-400/70 text-xs mb-3">MILESTONES</div>
|
||||||
{/* Add Purchase Button */}
|
<div className="overflow-x-auto">
|
||||||
{onAddPurchase && (
|
<table className="w-full text-sm font-mono">
|
||||||
<button
|
<thead>
|
||||||
onClick={onAddPurchase}
|
<tr className="border-b border-red-500/20">
|
||||||
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"
|
<th className="text-left text-red-400/60 text-xs py-2">DESCRIPTION</th>
|
||||||
aria-label="Add purchase"
|
<th className="text-right text-red-400/60 text-xs py-2">SHARES</th>
|
||||||
>
|
<th className="text-right text-red-400/60 text-xs py-2">SWR 3%</th>
|
||||||
<Plus className="w-4 h-4" />
|
<th className="text-right text-red-400/60 text-xs py-2">SWR 4%</th>
|
||||||
<span className="font-mono">ADD PURCHASE</span>
|
</tr>
|
||||||
</button>
|
</thead>
|
||||||
)}
|
<tbody>
|
||||||
|
{/* Create combined array with current position and milestones, sorted by target */}
|
||||||
{/* Add Milestone Button */}
|
{[
|
||||||
{onAddMilestone && (
|
...milestones.map(m => ({ ...m, isCurrent: false })),
|
||||||
<button
|
{
|
||||||
onClick={onAddMilestone}
|
target: stats.totalShares,
|
||||||
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"
|
description: 'CURRENT',
|
||||||
aria-label="Add milestone"
|
created_at: '',
|
||||||
>
|
isCurrent: true
|
||||||
<Plus className="w-4 h-4" />
|
}
|
||||||
<span className="font-mono">ADD MILESTONE</span>
|
]
|
||||||
</button>
|
.sort((a, b) => a.target - b.target)
|
||||||
)}
|
.map((item, index) => {
|
||||||
|
const swr3 = stats.currentPrice ? item.target * stats.currentPrice * 0.03 : 0;
|
||||||
|
const swr4 = stats.currentPrice ? item.target * stats.currentPrice * 0.04 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"border-b border-red-500/10",
|
||||||
|
item.isCurrent
|
||||||
|
? "bg-red-500/10 text-red-300"
|
||||||
|
: stats.totalShares >= item.target
|
||||||
|
? "text-green-400/80"
|
||||||
|
: "text-red-400/70"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
{item.isCurrent ? (
|
||||||
|
<span className="font-bold">{item.description}</span>
|
||||||
|
) : (
|
||||||
|
item.description
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-2 pr-4">
|
||||||
|
{Math.floor(item.target).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-2 pr-4">
|
||||||
|
{stats.currentPrice ? formatCurrency(swr3) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-2">
|
||||||
|
{stats.currentPrice ? formatCurrency(swr4) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -16,9 +16,10 @@ interface PriceUpdateFormData {
|
||||||
interface UpdatePriceFormProps {
|
interface UpdatePriceFormProps {
|
||||||
currentPrice?: number;
|
currentPrice?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UpdatePriceForm({ currentPrice, className }: UpdatePriceFormProps) {
|
export default function UpdatePriceForm({ currentPrice, className, onSuccess }: UpdatePriceFormProps) {
|
||||||
const { data, setData, post, processing, errors } = useForm<PriceUpdateFormData>({
|
const { data, setData, post, processing, errors } = useForm<PriceUpdateFormData>({
|
||||||
date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
|
date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
|
||||||
price: currentPrice?.toString() || '',
|
price: currentPrice?.toString() || '',
|
||||||
|
|
@ -31,6 +32,7 @@ export default function UpdatePriceForm({ currentPrice, className }: UpdatePrice
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Keep the date, reset only price if needed
|
// Keep the date, reset only price if needed
|
||||||
// User might want to update same day multiple times
|
// User might want to update same day multiple times
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export default function Dashboard() {
|
||||||
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||||
const [showProgressBar, setShowProgressBar] = useState(false);
|
const [showProgressBar, setShowProgressBar] = useState(false);
|
||||||
const [showStatsBox, setShowStatsBox] = useState(false);
|
const [showStatsBox, setShowStatsBox] = useState(false);
|
||||||
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | null>(null);
|
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | 'price' | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Fetch purchase summary, current price, and milestones
|
// Fetch purchase summary, current price, and milestones
|
||||||
|
|
@ -98,6 +98,19 @@ export default function Dashboard() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Refresh price data after successful update
|
||||||
|
const handlePriceSuccess = async () => {
|
||||||
|
try {
|
||||||
|
const priceResponse = await fetch('/pricing/current');
|
||||||
|
if (priceResponse.ok) {
|
||||||
|
const price = await priceResponse.json();
|
||||||
|
setPriceData(price);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh price data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// Calculate portfolio stats
|
// Calculate portfolio stats
|
||||||
const currentValue = priceData.current_price
|
const currentValue = priceData.current_price
|
||||||
|
|
@ -180,6 +193,7 @@ export default function Dashboard() {
|
||||||
milestones={milestones}
|
milestones={milestones}
|
||||||
onAddPurchase={() => setActiveForm('purchase')}
|
onAddPurchase={() => setActiveForm('purchase')}
|
||||||
onAddMilestone={() => setActiveForm('milestone')}
|
onAddMilestone={() => setActiveForm('milestone')}
|
||||||
|
onUpdatePrice={() => setActiveForm('price')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -190,6 +204,7 @@ export default function Dashboard() {
|
||||||
onClose={() => setActiveForm(null)}
|
onClose={() => setActiveForm(null)}
|
||||||
onPurchaseSuccess={handlePurchaseSuccess}
|
onPurchaseSuccess={handlePurchaseSuccess}
|
||||||
onMilestoneSuccess={handleMilestoneSuccess}
|
onMilestoneSuccess={handleMilestoneSuccess}
|
||||||
|
onPriceSuccess={handlePriceSuccess}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue