diff --git a/app/Http/Controllers/Milestones/MilestoneController.php b/app/Http/Controllers/Milestones/MilestoneController.php new file mode 100644 index 0000000..871b9a5 --- /dev/null +++ b/app/Http/Controllers/Milestones/MilestoneController.php @@ -0,0 +1,34 @@ +validate([ + 'target' => 'required|integer|min:1', + 'description' => 'required|string|max:255', + ]); + + Milestone::create([ + 'target' => $request->target, + 'description' => $request->description, + ]); + + return back()->with('success', 'Milestone created successfully'); + } + + public function index(): JsonResponse + { + $milestones = Milestone::orderBy('target')->get(); + + return response()->json($milestones); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Pricing/PricingController.php b/app/Http/Controllers/Pricing/PricingController.php new file mode 100644 index 0000000..bb07156 --- /dev/null +++ b/app/Http/Controllers/Pricing/PricingController.php @@ -0,0 +1,50 @@ +json([ + 'current_price' => $price, + ]); + } + + public function update(Request $request) + { + $validated = $request->validate([ + 'date' => 'required|date|before_or_equal:today', + 'price' => 'required|numeric|min:0.0001', + ]); + + $assetPrice = AssetPrice::updatePrice($validated['date'], $validated['price']); + + return back()->with('success', 'Asset price updated successfully!'); + } + + public function history(Request $request): JsonResponse + { + $limit = $request->get('limit', 30); + $history = AssetPrice::history($limit); + + return response()->json($history); + } + + public function forDate(Request $request, string $date): JsonResponse + { + $price = AssetPrice::forDate($date); + + return response()->json([ + 'date' => $date, + 'price' => $price, + ]); + } +} diff --git a/app/Http/Controllers/Transactions/PurchaseController.php b/app/Http/Controllers/Transactions/PurchaseController.php new file mode 100644 index 0000000..b0bd1a1 --- /dev/null +++ b/app/Http/Controllers/Transactions/PurchaseController.php @@ -0,0 +1,75 @@ +get(); + + return response()->json($purchases); + } + + public function store(Request $request): JsonResponse + { + $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 response()->json([ + 'success' => true, + 'message' => '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 response()->json([ + 'success' => true, + 'message' => 'Purchase deleted successfully!', + ]); + } +} diff --git a/app/Models/Milestone.php b/app/Models/Milestone.php new file mode 100644 index 0000000..98efa38 --- /dev/null +++ b/app/Models/Milestone.php @@ -0,0 +1,21 @@ + 'integer', + ]; +} diff --git a/app/Models/Pricing/AssetPrice.php b/app/Models/Pricing/AssetPrice.php new file mode 100644 index 0000000..2d3b6cf --- /dev/null +++ b/app/Models/Pricing/AssetPrice.php @@ -0,0 +1,60 @@ + 'date', + 'price' => 'decimal:4', + ]; + + public static function current(): ?float + { + $latestPrice = static::latest('date')->first(); + + return $latestPrice ? $latestPrice->price : null; + } + + public static function forDate(string $date): ?float + { + $price = static::where('date', '<=', $date) + ->orderBy('date', 'desc') + ->first(); + + return $price ? $price->price : null; + } + + public static function updatePrice(string $date, float $price): self + { + return static::updateOrCreate( + ['date' => $date], + ['price' => $price] + ); + } + + public static function history(int $limit = 30): Collection + { + return static::orderBy('date', 'desc')->limit($limit)->get(); + } +} 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/database/migrations/2025_07_10_152716_create_asset_prices_table.php b/database/migrations/2025_07_10_152716_create_asset_prices_table.php new file mode 100644 index 0000000..94ebab3 --- /dev/null +++ b/database/migrations/2025_07_10_152716_create_asset_prices_table.php @@ -0,0 +1,26 @@ +id(); + $table->date('date'); + $table->decimal('price', 10, 4); + $table->timestamps(); + + $table->unique('date'); + $table->index('date'); + }); + } + + public function down(): void + { + Schema::dropIfExists('asset_prices'); + } +}; diff --git a/database/migrations/2025_07_12_221324_create_milestones_table.php b/database/migrations/2025_07_12_221324_create_milestones_table.php new file mode 100644 index 0000000..da034cc --- /dev/null +++ b/database/migrations/2025_07_12_221324_create_milestones_table.php @@ -0,0 +1,29 @@ +id(); + $table->integer('target'); + $table->string('description'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('milestones'); + } +}; diff --git a/public/fonts/7segment.woff b/public/fonts/7segment.woff new file mode 100644 index 0000000..09ab315 Binary files /dev/null and b/public/fonts/7segment.woff differ diff --git a/resources/css/app.css b/resources/css/app.css index 43fdb4a..912a683 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -5,12 +5,26 @@ @source '../views'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@font-face { + font-family: '7Segment'; + src: url('/fonts/7segment.woff') format('woff'); + font-weight: normal; + font-style: normal; +} + +.font-digital { + font-family: '7Segment', monospace; +} + @custom-variant dark (&:is(.dark *)); @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); --radius-sm: calc(var(--radius) - 4px); diff --git a/resources/js/components/app-content.tsx b/resources/js/components/Display/AppContent.tsx similarity index 100% rename from resources/js/components/app-content.tsx rename to resources/js/components/Display/AppContent.tsx diff --git a/resources/js/components/app-header.tsx b/resources/js/components/Display/AppHeader.tsx similarity index 96% rename from resources/js/components/app-header.tsx rename to resources/js/components/Display/AppHeader.tsx index 831f2c3..2bc1fe4 100644 --- a/resources/js/components/app-header.tsx +++ b/resources/js/components/Display/AppHeader.tsx @@ -1,19 +1,19 @@ -import { Breadcrumbs } from '@/components/breadcrumbs'; +import { Breadcrumbs } from '@/components/Display/Breadcrumbs'; import { Icon } from '@/components/icon'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; -import { NavigationMenu, NavigationMenuItem, NavigationMenuList, navigationMenuTriggerStyle } from '@/components/ui/navigation-menu'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/DropdownMenu'; +import { NavigationMenu, NavigationMenuItem, NavigationMenuList, navigationMenuTriggerStyle } from '@/components/ui/NavigationMenu'; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { UserMenuContent } from '@/components/user-menu-content'; +import { UserMenuContent } from '@/components/Settings/UserMenuContent'; import { useInitials } from '@/hooks/use-initials'; import { cn } from '@/lib/utils'; 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 AppLogo from './app-logo'; -import AppLogoIcon from './app-logo-icon'; +import AppLogo from './AppLogo'; +import AppLogoIcon from './AppLogoIcon'; const mainNavItems: NavItem[] = [ { diff --git a/resources/js/components/app-logo.tsx b/resources/js/components/Display/AppLogo.tsx similarity index 92% rename from resources/js/components/app-logo.tsx rename to resources/js/components/Display/AppLogo.tsx index 69bdcb8..6c377cf 100644 --- a/resources/js/components/app-logo.tsx +++ b/resources/js/components/Display/AppLogo.tsx @@ -1,4 +1,4 @@ -import AppLogoIcon from './app-logo-icon'; +import AppLogoIcon from './AppLogoIcon'; export default function AppLogo() { return ( diff --git a/resources/js/components/app-logo-icon.tsx b/resources/js/components/Display/AppLogoIcon.tsx similarity index 100% rename from resources/js/components/app-logo-icon.tsx rename to resources/js/components/Display/AppLogoIcon.tsx diff --git a/resources/js/components/app-shell.tsx b/resources/js/components/Display/AppShell.tsx similarity index 100% rename from resources/js/components/app-shell.tsx rename to resources/js/components/Display/AppShell.tsx diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/Display/AppSidebar.tsx similarity index 88% rename from resources/js/components/app-sidebar.tsx rename to resources/js/components/Display/AppSidebar.tsx index c517672..b2151b8 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/Display/AppSidebar.tsx @@ -1,11 +1,11 @@ -import { NavFooter } from '@/components/nav-footer'; -import { NavMain } from '@/components/nav-main'; -import { NavUser } from '@/components/nav-user'; +import { NavFooter } from '@/components/Display/NavFooter'; +import { NavMain } from '@/components/Display/NavMain'; +import { NavUser } from '@/components/Display/NavUser'; import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'; import { type NavItem } from '@/types'; import { Link } from '@inertiajs/react'; import { BookOpen, Folder, LayoutGrid } from 'lucide-react'; -import AppLogo from './app-logo'; +import AppLogo from './AppLogo'; const mainNavItems: NavItem[] = [ { diff --git a/resources/js/components/app-sidebar-header.tsx b/resources/js/components/Display/AppSidebarHeader.tsx similarity index 91% rename from resources/js/components/app-sidebar-header.tsx rename to resources/js/components/Display/AppSidebarHeader.tsx index 6a3128b..10c7da2 100644 --- a/resources/js/components/app-sidebar-header.tsx +++ b/resources/js/components/Display/AppSidebarHeader.tsx @@ -1,4 +1,4 @@ -import { Breadcrumbs } from '@/components/breadcrumbs'; +import { Breadcrumbs } from '@/components/Display/Breadcrumbs'; import { SidebarTrigger } from '@/components/ui/sidebar'; import { type BreadcrumbItem as BreadcrumbItemType } from '@/types'; diff --git a/resources/js/components/breadcrumbs.tsx b/resources/js/components/Display/Breadcrumbs.tsx similarity index 100% rename from resources/js/components/breadcrumbs.tsx rename to resources/js/components/Display/Breadcrumbs.tsx diff --git a/resources/js/components/Display/InlineForm.tsx b/resources/js/components/Display/InlineForm.tsx new file mode 100644 index 0000000..9d6b693 --- /dev/null +++ b/resources/js/components/Display/InlineForm.tsx @@ -0,0 +1,78 @@ +import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm'; +import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm'; +import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm'; +import { cn } from '@/lib/utils'; +import { X } from 'lucide-react'; + +interface InlineFormProps { + type: 'purchase' | 'milestone' | 'price' | null; + onClose: () => void; + onPurchaseSuccess?: () => void; + onMilestoneSuccess?: () => void; + onPriceSuccess?: () => void; + className?: string; +} + +export default function InlineForm({ + type, + onClose, + onPurchaseSuccess, + onMilestoneSuccess, + onPriceSuccess, + className +}: InlineFormProps) { + if (!type) return null; + + const title = type === 'purchase' ? 'ADD PURCHASE' : type === 'milestone' ? 'ADD MILESTONE' : 'UPDATE PRICE'; + + return ( +
+ {/* Header */} +
+

+ {title} +

+ +
+ + {/* Form Content */} +
+ {type === 'purchase' ? ( + { + if (onPurchaseSuccess) onPurchaseSuccess(); + onClose(); + }} + /> + ) : type === 'milestone' ? ( + { + if (onMilestoneSuccess) onMilestoneSuccess(); + onClose(); + }} + /> + ) : ( + { + if (onPriceSuccess) onPriceSuccess(); + onClose(); + }} + /> + )} +
+
+ ); +} \ No newline at end of file diff --git a/resources/js/components/Display/LedDisplay.tsx b/resources/js/components/Display/LedDisplay.tsx new file mode 100644 index 0000000..77d24a8 --- /dev/null +++ b/resources/js/components/Display/LedDisplay.tsx @@ -0,0 +1,60 @@ +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, + onClick +}: LedDisplayProps) { + const [displayValue, setDisplayValue] = useState(0); + + // Animate number changes + useEffect(() => { + setDisplayValue(value); + return; + }, [value]); + + // Format number with zero-padding for consistent width + const formatValue = (value: number) => { + // Always pad to 5 digits for consistent display width + const integerPart = Math.floor(value); + return integerPart.toString().padStart(5, '0'); + }; + + const formattedValue = formatValue(displayValue); + + return ( +
+
+
+ {formattedValue} +
+
+
+ ); +} diff --git a/resources/js/components/nav-footer.tsx b/resources/js/components/Display/NavFooter.tsx similarity index 100% rename from resources/js/components/nav-footer.tsx rename to resources/js/components/Display/NavFooter.tsx diff --git a/resources/js/components/nav-main.tsx b/resources/js/components/Display/NavMain.tsx similarity index 100% rename from resources/js/components/nav-main.tsx rename to resources/js/components/Display/NavMain.tsx diff --git a/resources/js/components/nav-user.tsx b/resources/js/components/Display/NavUser.tsx similarity index 89% rename from resources/js/components/nav-user.tsx rename to resources/js/components/Display/NavUser.tsx index 386be8f..7918dfc 100644 --- a/resources/js/components/nav-user.tsx +++ b/resources/js/components/Display/NavUser.tsx @@ -1,7 +1,7 @@ -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/DropdownMenu'; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar'; -import { UserInfo } from '@/components/user-info'; -import { UserMenuContent } from '@/components/user-menu-content'; +import { UserInfo } from '@/components/Settings/UserInfo'; +import { UserMenuContent } from '@/components/Settings/UserMenuContent'; import { useIsMobile } from '@/hooks/use-mobile'; import { type SharedData } from '@/types'; import { usePage } from '@inertiajs/react'; diff --git a/resources/js/components/Display/ProgressBar.tsx b/resources/js/components/Display/ProgressBar.tsx new file mode 100644 index 0000000..05fdf7e --- /dev/null +++ b/resources/js/components/Display/ProgressBar.tsx @@ -0,0 +1,69 @@ +import { cn } from '@/lib/utils'; + +interface Milestone { + target: number; + description: string; + created_at: string; +} + +interface ProgressBarProps { + currentShares: number; + milestones: Milestone[]; + selectedMilestoneIndex?: number; + className?: string; + onClick?: () => void; +} + +export default function ProgressBar({ + currentShares, + milestones, + selectedMilestoneIndex = 0, + className, + onClick +}: ProgressBarProps) { + // Get the selected milestone for progress calculation + const selectedMilestone = milestones.length > 0 && selectedMilestoneIndex < milestones.length + ? milestones[selectedMilestoneIndex] + : null; + + // Calculate progress percentage + const progressPercentage = selectedMilestone + ? Math.min((currentShares / selectedMilestone.target) * 100, 100) + : 0; + return ( +
+ {/* Progress Bar Container */} +
+ {/* Old-school progress bar with overlaid text */} +
+ {/* Inner container */} +
+ {/* Progress fill */} +
+ + {/* Text overlay */} + {selectedMilestone && ( +
+ {/* Base text (red on black background) */} +
+ {progressPercentage.toFixed(1)}% +
+
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/resources/js/components/Display/StatsBox.tsx b/resources/js/components/Display/StatsBox.tsx new file mode 100644 index 0000000..eafa4aa --- /dev/null +++ b/resources/js/components/Display/StatsBox.tsx @@ -0,0 +1,214 @@ +import { cn } from '@/lib/utils'; +import { Plus, ChevronRight } from 'lucide-react'; +import { useState } from 'react'; + +interface Milestone { + target: number; + description: string; + created_at: string; +} + +interface StatsBoxProps { + stats: { + totalShares: number; + totalInvestment: number; + averageCostPerShare: number; + currentPrice?: number; + currentValue?: number; + profitLoss?: number; + profitLossPercentage?: number; + }; + milestones?: Milestone[]; + selectedMilestoneIndex?: number; + onMilestoneSelect?: (index: number) => void; + className?: string; + onAddPurchase?: () => void; + onAddMilestone?: () => void; + onUpdatePrice?: () => void; +} + +export default function StatsBox({ + stats, + milestones = [], + selectedMilestoneIndex = 0, + onMilestoneSelect, + className, + onAddPurchase, + onAddMilestone, + onUpdatePrice +}: StatsBoxProps) { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + const handleCycleMilestone = () => { + if (milestones.length === 0 || !onMilestoneSelect) return; + const nextIndex = (selectedMilestoneIndex + 1) % milestones.length; + onMilestoneSelect(nextIndex); + }; + 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 ( +
+
+ {/* STATS Title and Current Price */} +
+

+ STATS +

+
+ {stats.currentPrice && ( +
+ VWCE: {formatCurrencyDetailed(stats.currentPrice)} +
+ )} + + {/* Action Dropdown */} +
+ + + {/* Dropdown Menu */} + {isDropdownOpen && ( +
+ {onAddPurchase && ( + + )} + {onAddMilestone && ( + + )} + {onUpdatePrice && ( + + )} +
+ )} +
+ + {/* Milestone Cycle Button */} + {milestones.length > 1 && ( + + )} +
+
+ + {/* Milestone Table */} +
+
MILESTONES
+
+ + + + + + + + + + + {/* Current position row */} + + + + + + + + {/* Render milestones after current */} + {milestones.map((milestone, index) => { + const swr3 = stats.currentPrice ? milestone.target * stats.currentPrice * 0.03 : 0; + const swr4 = stats.currentPrice ? milestone.target * stats.currentPrice * 0.04 : 0; + + const isSelectedMilestone = index === selectedMilestoneIndex; + + return ( + + + + + + + ); + })} + +
DESCRIPTIONSHARESSWR 3%SWR 4%
CURRENT + {Math.floor(stats.totalShares).toLocaleString()} + + {stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.03) : 'N/A'} + + {stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.04) : 'N/A'} +
+ {milestone.description} + + {Math.floor(milestone.target).toLocaleString()} + + {stats.currentPrice ? formatCurrency(swr3) : 'N/A'} + + {stats.currentPrice ? formatCurrency(swr4) : 'N/A'} +
+
+
+
+
+ ); +} diff --git a/resources/js/components/heading-small.tsx b/resources/js/components/HeadingSmall.tsx similarity index 100% rename from resources/js/components/heading-small.tsx rename to resources/js/components/HeadingSmall.tsx diff --git a/resources/js/components/input-error.tsx b/resources/js/components/InputError.tsx similarity index 100% rename from resources/js/components/input-error.tsx rename to resources/js/components/InputError.tsx diff --git a/resources/js/components/Milestones/AddMilestoneForm.tsx b/resources/js/components/Milestones/AddMilestoneForm.tsx new file mode 100644 index 0000000..629831f --- /dev/null +++ b/resources/js/components/Milestones/AddMilestoneForm.tsx @@ -0,0 +1,82 @@ +import { Button } from '@/components/ui/button'; +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 } from 'react'; + +interface MilestoneFormData { + target: string; + description: string; + [key: string]: string; +} + +interface AddMilestoneFormProps { + onSuccess?: () => void; +} + +export default function AddMilestoneForm({ onSuccess }: AddMilestoneFormProps) { + const { data, setData, post, processing, errors, reset } = useForm({ + target: '', + description: '', + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + + post(route('milestones.store'), { + onSuccess: () => { + reset(); + if (onSuccess) { + onSuccess(); + } + }, + }); + }; + + return ( +
+
+
+
+ + setData('target', e.target.value)} + className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30" + /> + +
+ +
+ + setData('description', e.target.value)} + className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30" + /> + +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/resources/js/components/Pricing/UpdatePriceForm.tsx b/resources/js/components/Pricing/UpdatePriceForm.tsx new file mode 100644 index 0000000..bd18cb9 --- /dev/null +++ b/resources/js/components/Pricing/UpdatePriceForm.tsx @@ -0,0 +1,93 @@ +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 } from 'react'; + +interface PriceUpdateFormData { + date: string; + price: string; + [key: string]: string; +} + +interface UpdatePriceFormProps { + currentPrice?: number; + className?: string; + onSuccess?: () => void; +} + +export default function UpdatePriceForm({ currentPrice, className, onSuccess }: UpdatePriceFormProps) { + const { data, setData, post, processing, errors } = useForm({ + date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format + price: currentPrice?.toString() || '', + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + + post(route('pricing.update'), { + onSuccess: () => { + // Keep the date, reset only price if needed + // User might want to update same day multiple times + if (onSuccess) onSuccess(); + }, + }); + }; + + return ( + + + Update Asset Price + {currentPrice && ( +

+ Current price: €{currentPrice.toFixed(4)} +

+ )} +
+ +
+
+ + setData('date', e.target.value)} + max={new Date().toISOString().split('T')[0]} + /> + +
+ +
+ + setData('price', e.target.value)} + /> +

+ Price per unit/share of the asset +

+ +
+ + +
+
+
+ ); +} diff --git a/resources/js/components/appearance-dropdown.tsx b/resources/js/components/Settings/AppearanceDropdown.tsx similarity index 97% rename from resources/js/components/appearance-dropdown.tsx rename to resources/js/components/Settings/AppearanceDropdown.tsx index 89a4586..ee5032f 100644 --- a/resources/js/components/appearance-dropdown.tsx +++ b/resources/js/components/Settings/AppearanceDropdown.tsx @@ -1,5 +1,5 @@ import { Button } from '@/components/ui/button'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/DropdownMenu'; import { useAppearance } from '@/hooks/use-appearance'; import { Monitor, Moon, Sun } from 'lucide-react'; import { HTMLAttributes } from 'react'; diff --git a/resources/js/components/appearance-tabs.tsx b/resources/js/components/Settings/AppearanceTabs.tsx similarity index 100% rename from resources/js/components/appearance-tabs.tsx rename to resources/js/components/Settings/AppearanceTabs.tsx diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/Settings/DeleteUser.tsx similarity index 97% rename from resources/js/components/delete-user.tsx rename to resources/js/components/Settings/DeleteUser.tsx index e1f8788..976fae0 100644 --- a/resources/js/components/delete-user.tsx +++ b/resources/js/components/Settings/DeleteUser.tsx @@ -1,12 +1,12 @@ import { useForm } from '@inertiajs/react'; import { FormEventHandler, useRef } from 'react'; -import InputError from '@/components/input-error'; +import InputError from '@/components/InputError'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import HeadingSmall from '@/components/heading-small'; +import HeadingSmall from '@/components/HeadingSmall'; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; diff --git a/resources/js/components/user-info.tsx b/resources/js/components/Settings/UserInfo.tsx similarity index 100% rename from resources/js/components/user-info.tsx rename to resources/js/components/Settings/UserInfo.tsx diff --git a/resources/js/components/user-menu-content.tsx b/resources/js/components/Settings/UserMenuContent.tsx similarity index 92% rename from resources/js/components/user-menu-content.tsx rename to resources/js/components/Settings/UserMenuContent.tsx index c002b19..f57285f 100644 --- a/resources/js/components/user-menu-content.tsx +++ b/resources/js/components/Settings/UserMenuContent.tsx @@ -1,5 +1,5 @@ -import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'; -import { UserInfo } from '@/components/user-info'; +import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/DropdownMenu'; +import { UserInfo } from '@/components/Settings/UserInfo'; import { useMobileNavigation } from '@/hooks/use-mobile-navigation'; import { type User } from '@/types'; import { Link, router } from '@inertiajs/react'; diff --git a/resources/js/components/text-link.tsx b/resources/js/components/TextLink.tsx similarity index 100% rename from resources/js/components/text-link.tsx rename to resources/js/components/TextLink.tsx diff --git a/resources/js/components/Transactions/AddPurchaseForm.tsx b/resources/js/components/Transactions/AddPurchaseForm.tsx new file mode 100644 index 0000000..68732b2 --- /dev/null +++ b/resources/js/components/Transactions/AddPurchaseForm.tsx @@ -0,0 +1,133 @@ +import { Button } from '@/components/ui/button'; +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; + [key: string]: string; +} + +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: '', + 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]); + if (onSuccess) { + onSuccess(); + } + }, + }); + }; + + return ( +
+
+
+
+ + 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-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30" + /> +

+ Auto-calculated from shares × price +

+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/resources/js/components/ui/dropdown-menu.tsx b/resources/js/components/ui/DropdownMenu.tsx similarity index 100% rename from resources/js/components/ui/dropdown-menu.tsx rename to resources/js/components/ui/DropdownMenu.tsx diff --git a/resources/js/components/ui/navigation-menu.tsx b/resources/js/components/ui/NavigationMenu.tsx similarity index 100% rename from resources/js/components/ui/navigation-menu.tsx rename to resources/js/components/ui/NavigationMenu.tsx diff --git a/resources/js/components/ui/placeholder-pattern.tsx b/resources/js/components/ui/PlaceholderPattern.tsx similarity index 100% rename from resources/js/components/ui/placeholder-pattern.tsx rename to resources/js/components/ui/PlaceholderPattern.tsx diff --git a/resources/js/components/ui/toggle-group.tsx b/resources/js/components/ui/ToggleGroup.tsx similarity index 100% rename from resources/js/components/ui/toggle-group.tsx rename to resources/js/components/ui/ToggleGroup.tsx diff --git a/resources/js/layouts/app/app-header-layout.tsx b/resources/js/layouts/app/app-header-layout.tsx index 9cde5da..feda5e5 100644 --- a/resources/js/layouts/app/app-header-layout.tsx +++ b/resources/js/layouts/app/app-header-layout.tsx @@ -1,6 +1,6 @@ -import { AppContent } from '@/components/app-content'; -import { AppHeader } from '@/components/app-header'; -import { AppShell } from '@/components/app-shell'; +import { AppContent } from '@/components/Display/AppContent'; +import { AppHeader } from '@/components/Display/AppHeader'; +import { AppShell } from '@/components/Display/AppShell'; import { type BreadcrumbItem } from '@/types'; import type { PropsWithChildren } from 'react'; diff --git a/resources/js/layouts/app/app-sidebar-layout.tsx b/resources/js/layouts/app/app-sidebar-layout.tsx index 7fa11d1..7fe141b 100644 --- a/resources/js/layouts/app/app-sidebar-layout.tsx +++ b/resources/js/layouts/app/app-sidebar-layout.tsx @@ -1,7 +1,7 @@ -import { AppContent } from '@/components/app-content'; -import { AppShell } from '@/components/app-shell'; -import { AppSidebar } from '@/components/app-sidebar'; -import { AppSidebarHeader } from '@/components/app-sidebar-header'; +import { AppContent } from '@/components/Display/AppContent'; +import { AppShell } from '@/components/Display/AppShell'; +import { AppSidebar } from '@/components/Display/AppSidebar'; +import { AppSidebarHeader } from '@/components/Display/AppSidebarHeader'; import { type BreadcrumbItem } from '@/types'; import { type PropsWithChildren } from 'react'; diff --git a/resources/js/layouts/auth/auth-card-layout.tsx b/resources/js/layouts/auth/auth-card-layout.tsx index d0bfc4e..72d09cc 100644 --- a/resources/js/layouts/auth/auth-card-layout.tsx +++ b/resources/js/layouts/auth/auth-card-layout.tsx @@ -1,4 +1,4 @@ -import AppLogoIcon from '@/components/app-logo-icon'; +import AppLogoIcon from '@/components/Display/AppLogoIcon'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Link } from '@inertiajs/react'; import { type PropsWithChildren } from 'react'; diff --git a/resources/js/layouts/auth/auth-simple-layout.tsx b/resources/js/layouts/auth/auth-simple-layout.tsx index 6de3efc..7383ee5 100644 --- a/resources/js/layouts/auth/auth-simple-layout.tsx +++ b/resources/js/layouts/auth/auth-simple-layout.tsx @@ -1,4 +1,4 @@ -import AppLogoIcon from '@/components/app-logo-icon'; +import AppLogoIcon from '@/components/Display/AppLogoIcon'; import { Link } from '@inertiajs/react'; import { type PropsWithChildren } from 'react'; diff --git a/resources/js/layouts/auth/auth-split-layout.tsx b/resources/js/layouts/auth/auth-split-layout.tsx index 03daf1c..6a47bbf 100644 --- a/resources/js/layouts/auth/auth-split-layout.tsx +++ b/resources/js/layouts/auth/auth-split-layout.tsx @@ -1,4 +1,4 @@ -import AppLogoIcon from '@/components/app-logo-icon'; +import AppLogoIcon from '@/components/Display/AppLogoIcon'; import { type SharedData } from '@/types'; import { Link, usePage } from '@inertiajs/react'; import { type PropsWithChildren } from 'react'; diff --git a/resources/js/pages/auth/confirm-password.tsx b/resources/js/pages/auth/confirm-password.tsx index bc1ae57..50907b9 100644 --- a/resources/js/pages/auth/confirm-password.tsx +++ b/resources/js/pages/auth/confirm-password.tsx @@ -3,7 +3,7 @@ import { Head, useForm } from '@inertiajs/react'; import { LoaderCircle } from 'lucide-react'; import { FormEventHandler } from 'react'; -import InputError from '@/components/input-error'; +import InputError from '@/components/InputError'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; diff --git a/resources/js/pages/auth/forgot-password.tsx b/resources/js/pages/auth/forgot-password.tsx index 86d1b30..a69c3bc 100644 --- a/resources/js/pages/auth/forgot-password.tsx +++ b/resources/js/pages/auth/forgot-password.tsx @@ -3,8 +3,8 @@ import { Head, useForm } from '@inertiajs/react'; import { LoaderCircle } from 'lucide-react'; import { FormEventHandler } from 'react'; -import InputError from '@/components/input-error'; -import TextLink from '@/components/text-link'; +import InputError from '@/components/InputError'; +import TextLink from '@/components/TextLink'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index 28f76f0..8df7bfd 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -2,8 +2,8 @@ import { Head, useForm } from '@inertiajs/react'; import { LoaderCircle } from 'lucide-react'; import { FormEventHandler } from 'react'; -import InputError from '@/components/input-error'; -import TextLink from '@/components/text-link'; +import InputError from '@/components/InputError'; +import TextLink from '@/components/TextLink'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; diff --git a/resources/js/pages/auth/register.tsx b/resources/js/pages/auth/register.tsx index 6b0faa8..35091ba 100644 --- a/resources/js/pages/auth/register.tsx +++ b/resources/js/pages/auth/register.tsx @@ -2,8 +2,8 @@ import { Head, useForm } from '@inertiajs/react'; import { LoaderCircle } from 'lucide-react'; import { FormEventHandler } from 'react'; -import InputError from '@/components/input-error'; -import TextLink from '@/components/text-link'; +import InputError from '@/components/InputError'; +import TextLink from '@/components/TextLink'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; diff --git a/resources/js/pages/auth/reset-password.tsx b/resources/js/pages/auth/reset-password.tsx index 8ea5303..816b11d 100644 --- a/resources/js/pages/auth/reset-password.tsx +++ b/resources/js/pages/auth/reset-password.tsx @@ -2,7 +2,7 @@ import { Head, useForm } from '@inertiajs/react'; import { LoaderCircle } from 'lucide-react'; import { FormEventHandler } from 'react'; -import InputError from '@/components/input-error'; +import InputError from '@/components/InputError'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; diff --git a/resources/js/pages/auth/verify-email.tsx b/resources/js/pages/auth/verify-email.tsx index b4f7846..ec9a5ce 100644 --- a/resources/js/pages/auth/verify-email.tsx +++ b/resources/js/pages/auth/verify-email.tsx @@ -3,7 +3,7 @@ import { Head, useForm } from '@inertiajs/react'; import { LoaderCircle } from 'lucide-react'; import { FormEventHandler } from 'react'; -import TextLink from '@/components/text-link'; +import TextLink from '@/components/TextLink'; import { Button } from '@/components/ui/button'; import AuthLayout from '@/layouts/auth-layout'; diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 3f73f02..6753014 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -1,35 +1,225 @@ -import { PlaceholderPattern } from '@/components/ui/placeholder-pattern'; -import AppLayout from '@/layouts/app-layout'; -import { type BreadcrumbItem } from '@/types'; +import LedDisplay from '@/components/Display/LedDisplay'; +import InlineForm from '@/components/Display/InlineForm'; +import ProgressBar from '@/components/Display/ProgressBar'; +import StatsBox from '@/components/Display/StatsBox'; 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; +} + +interface Milestone { + target: number; + description: string; + created_at: string; +} export default function Dashboard() { - return ( - - -
-
-
- -
-
- -
-
- + const [purchaseData, setPurchaseData] = useState({ + total_shares: 0, + total_investment: 0, + average_cost_per_share: 0, + }); + + const [priceData, setPriceData] = useState({ + current_price: null, + }); + + const [milestones, setMilestones] = useState([]); + const [selectedMilestoneIndex, setSelectedMilestoneIndex] = useState(0); + const [showProgressBar, setShowProgressBar] = useState(false); + const [showStatsBox, setShowStatsBox] = useState(false); + const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | 'price' | null>(null); + const [loading, setLoading] = useState(true); + + // Fetch purchase summary, current price, and milestones + useEffect(() => { + const fetchData = async () => { + try { + const [purchaseResponse, priceResponse, milestonesResponse] = await Promise.all([ + fetch('/purchases/summary'), + fetch('/pricing/current'), + fetch('/milestones'), + ]); + + if (purchaseResponse.ok) { + const purchases = await purchaseResponse.json(); + setPurchaseData(purchases); + } + + if (priceResponse.ok) { + const price = await priceResponse.json(); + setPriceData(price); + } + + if (milestonesResponse.ok) { + const milestonesData = await milestonesResponse.json(); + setMilestones(milestonesData); + } + } 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); + } + }; + + // Refresh milestones after successful creation + const handleMilestoneSuccess = async () => { + try { + const milestonesResponse = await fetch('/milestones'); + if (milestonesResponse.ok) { + const milestonesData = await milestonesResponse.json(); + setMilestones(milestonesData); + // Reset to first milestone when milestones change + setSelectedMilestoneIndex(0); + } + } catch (error) { + console.error('Failed to refresh milestone data:', error); + } + }; + + // Handle milestone selection + const handleMilestoneSelect = (index: number) => { + setSelectedMilestoneIndex(index); + }; + + // 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 + 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...
-
- + + ); + } + + // 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 ( + <> + + + {/* Stacked Layout */} +
+
+ {/* Box 1: LED Number Display - Fixed position from top */} +
+ +
+ + {/* Box 2: Progress Bar (toggleable) */} +
+ +
+ + {/* Box 3: Stats Box (toggleable) */} +
+ setActiveForm('purchase')} + onAddMilestone={() => setActiveForm('milestone')} + onUpdatePrice={() => setActiveForm('price')} + /> +
+ + {/* Box 4: Forms (only when active form is set) */} +
+ setActiveForm(null)} + onPurchaseSuccess={handlePurchaseSuccess} + onMilestoneSuccess={handleMilestoneSuccess} + onPriceSuccess={handlePriceSuccess} + /> +
- + ); } diff --git a/resources/js/pages/settings/appearance.tsx b/resources/js/pages/settings/appearance.tsx index 5099b25..94ab15a 100644 --- a/resources/js/pages/settings/appearance.tsx +++ b/resources/js/pages/settings/appearance.tsx @@ -1,7 +1,7 @@ import { Head } from '@inertiajs/react'; -import AppearanceTabs from '@/components/appearance-tabs'; -import HeadingSmall from '@/components/heading-small'; +import AppearanceTabs from '@/components/Settings/AppearanceTabs'; +import HeadingSmall from '@/components/HeadingSmall'; import { type BreadcrumbItem } from '@/types'; import AppLayout from '@/layouts/app-layout'; diff --git a/resources/js/pages/settings/password.tsx b/resources/js/pages/settings/password.tsx index 43540bb..4d16221 100644 --- a/resources/js/pages/settings/password.tsx +++ b/resources/js/pages/settings/password.tsx @@ -1,4 +1,4 @@ -import InputError from '@/components/input-error'; +import InputError from '@/components/InputError'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; import { type BreadcrumbItem } from '@/types'; @@ -6,7 +6,7 @@ import { Transition } from '@headlessui/react'; import { Head, useForm } from '@inertiajs/react'; import { FormEventHandler, useRef } from 'react'; -import HeadingSmall from '@/components/heading-small'; +import HeadingSmall from '@/components/HeadingSmall'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; diff --git a/resources/js/pages/settings/profile.tsx b/resources/js/pages/settings/profile.tsx index 3aeed3a..25b56be 100644 --- a/resources/js/pages/settings/profile.tsx +++ b/resources/js/pages/settings/profile.tsx @@ -3,9 +3,9 @@ import { Transition } from '@headlessui/react'; import { Head, Link, useForm, usePage } from '@inertiajs/react'; import { FormEventHandler } from 'react'; -import DeleteUser from '@/components/delete-user'; -import HeadingSmall from '@/components/heading-small'; -import InputError from '@/components/input-error'; +import DeleteUser from '@/components/Settings/DeleteUser'; +import HeadingSmall from '@/components/HeadingSmall'; +import InputError from '@/components/InputError'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 8218267..f132673 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -37,7 +37,9 @@ - + + + @routes @viteReactRefresh diff --git a/routes/web.php b/routes/web.php index 5e4cebd..3ee6392 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,16 +1,39 @@ 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'); +}); + +// Pricing routes +Route::prefix('pricing')->name('pricing.')->group(function () { + Route::get('/current', [PricingController::class, 'current'])->name('current'); + Route::post('/update', [PricingController::class, 'update'])->name('update'); + Route::get('/history', [PricingController::class, 'history'])->name('history'); + Route::get('/date/{date}', [PricingController::class, 'forDate'])->name('for-date'); +}); + +// Milestone routes +Route::prefix('milestones')->name('milestones.')->group(function () { + Route::get('/', [MilestoneController::class, 'index'])->name('index'); + Route::post('/', [MilestoneController::class, 'store'])->name('store'); }); require __DIR__.'/settings.php'; diff --git a/tests/Feature/MilestoneTest.php b/tests/Feature/MilestoneTest.php new file mode 100644 index 0000000..d86bb84 --- /dev/null +++ b/tests/Feature/MilestoneTest.php @@ -0,0 +1,59 @@ + 1500, + 'description' => 'First milestone' + ]); + + $this->assertDatabaseHas('milestones', [ + 'target' => 1500, + 'description' => 'First milestone' + ]); + + $this->assertEquals(1500, $milestone->target); + $this->assertEquals('First milestone', $milestone->description); + } + + public function test_can_fetch_milestones_via_api(): void + { + // Create test milestones + Milestone::create(['target' => 1500, 'description' => 'First milestone']); + Milestone::create(['target' => 3000, 'description' => 'Second milestone']); + + $response = $this->get('/milestones'); + + $response->assertStatus(200); + $response->assertJsonCount(2); + $response->assertJson([ + ['target' => 1500, 'description' => 'First milestone'], + ['target' => 3000, 'description' => 'Second milestone'] + ]); + } + + public function test_milestones_ordered_by_target(): void + { + // Create milestones in reverse order + Milestone::create(['target' => 3000, 'description' => 'Third']); + Milestone::create(['target' => 1000, 'description' => 'First']); + Milestone::create(['target' => 2000, 'description' => 'Second']); + + $response = $this->get('/milestones'); + + $milestones = $response->json(); + $this->assertEquals(1000, $milestones[0]['target']); + $this->assertEquals(2000, $milestones[1]['target']); + $this->assertEquals(3000, $milestones[2]['target']); + } +}