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 (
+
+ );
+}
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
+
+
+
+
+ | DESCRIPTION |
+ SHARES |
+ SWR 3% |
+ SWR 4% |
+
+
+
+ {/* Current position row */}
+
+ | 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'}
+ |
+
+
+ {/* 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 (
+
+ |
+ {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 (
+
+ );
+}
\ 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)}
+
+ )}
+
+
+
+
+
+ );
+}
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 (
+
+ );
+}
\ 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 (
+ <>
+
+
-
-
+ >
+ );
+ }
+
+ // 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']);
+ }
+}