Merge pull request 'feature/8-stats-bar' (#15) from feature/8-stats-bar into main
Reviewed-on: https://codeberg.org/lvl0/incr/pulls/15
This commit is contained in:
commit
2359e93d58
59 changed files with 1474 additions and 77 deletions
34
app/Http/Controllers/Milestones/MilestoneController.php
Normal file
34
app/Http/Controllers/Milestones/MilestoneController.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Milestones;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Milestone;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class MilestoneController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Controllers/Pricing/PricingController.php
Normal file
50
app/Http/Controllers/Pricing/PricingController.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Pricing;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Pricing\AssetPrice;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PricingController extends Controller
|
||||||
|
{
|
||||||
|
public function current(): JsonResponse
|
||||||
|
{
|
||||||
|
$price = AssetPrice::current();
|
||||||
|
|
||||||
|
return response()->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Http/Controllers/Transactions/PurchaseController.php
Normal file
75
app/Http/Controllers/Transactions/PurchaseController.php
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Transactions;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Transactions\Purchase;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Redirect;
|
||||||
|
|
||||||
|
class PurchaseController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$purchases = Purchase::orderBy('date', 'desc')->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!',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Models/Milestone.php
Normal file
21
app/Models/Milestone.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static create(array $array)
|
||||||
|
* @method static orderBy(string $string)
|
||||||
|
*/
|
||||||
|
class Milestone extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'target',
|
||||||
|
'description',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'target' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
60
app/Models/Pricing/AssetPrice.php
Normal file
60
app/Models/Pricing/AssetPrice.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Pricing;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static latest(string $string)
|
||||||
|
* @method static where(string $string, string $string1, string $date)
|
||||||
|
* @method static updateOrCreate(string[] $array, float[] $array1)
|
||||||
|
* @method static orderBy(string $string, string $string1)
|
||||||
|
* @property Carbon $date
|
||||||
|
* @property float $price
|
||||||
|
*/
|
||||||
|
class AssetPrice extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'date',
|
||||||
|
'price',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'date' => '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();
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Models/Transactions/Purchase.php
Normal file
52
app/Models/Transactions/Purchase.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Transactions;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Purchase extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'date',
|
||||||
|
'shares',
|
||||||
|
'price_per_share',
|
||||||
|
'total_cost',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'date' => '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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('purchases', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('asset_prices', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('milestones', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->integer('target');
|
||||||
|
$table->string('description');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('milestones');
|
||||||
|
}
|
||||||
|
};
|
||||||
BIN
public/fonts/7segment.woff
Normal file
BIN
public/fonts/7segment.woff
Normal file
Binary file not shown.
|
|
@ -5,12 +5,26 @@
|
||||||
@source '../views';
|
@source '../views';
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@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 *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans:
|
--font-sans:
|
||||||
'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
'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-lg: var(--radius);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
import { Breadcrumbs } from '@/components/Display/Breadcrumbs';
|
||||||
import { Icon } from '@/components/icon';
|
import { Icon } from '@/components/icon';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/DropdownMenu';
|
||||||
import { NavigationMenu, NavigationMenuItem, NavigationMenuList, navigationMenuTriggerStyle } from '@/components/ui/navigation-menu';
|
import { NavigationMenu, NavigationMenuItem, NavigationMenuList, navigationMenuTriggerStyle } from '@/components/ui/NavigationMenu';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
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 { useInitials } from '@/hooks/use-initials';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types';
|
import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types';
|
||||||
import { Link, usePage } from '@inertiajs/react';
|
import { Link, usePage } from '@inertiajs/react';
|
||||||
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react';
|
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react';
|
||||||
import AppLogo from './app-logo';
|
import AppLogo from './AppLogo';
|
||||||
import AppLogoIcon from './app-logo-icon';
|
import AppLogoIcon from './AppLogoIcon';
|
||||||
|
|
||||||
const mainNavItems: NavItem[] = [
|
const mainNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import AppLogoIcon from './app-logo-icon';
|
import AppLogoIcon from './AppLogoIcon';
|
||||||
|
|
||||||
export default function AppLogo() {
|
export default function AppLogo() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { NavFooter } from '@/components/nav-footer';
|
import { NavFooter } from '@/components/Display/NavFooter';
|
||||||
import { NavMain } from '@/components/nav-main';
|
import { NavMain } from '@/components/Display/NavMain';
|
||||||
import { NavUser } from '@/components/nav-user';
|
import { NavUser } from '@/components/Display/NavUser';
|
||||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
|
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
|
||||||
import { type NavItem } from '@/types';
|
import { type NavItem } from '@/types';
|
||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import { BookOpen, Folder, LayoutGrid } from 'lucide-react';
|
import { BookOpen, Folder, LayoutGrid } from 'lucide-react';
|
||||||
import AppLogo from './app-logo';
|
import AppLogo from './AppLogo';
|
||||||
|
|
||||||
const mainNavItems: NavItem[] = [
|
const mainNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
import { Breadcrumbs } from '@/components/Display/Breadcrumbs';
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||||
|
|
||||||
78
resources/js/components/Display/InlineForm.tsx
Normal file
78
resources/js/components/Display/InlineForm.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-black border-4 border-gray-800 rounded-lg",
|
||||||
|
"shadow-2xl shadow-red-500/20",
|
||||||
|
"p-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-red-500 font-mono tracking-wide text-lg">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-red-400 hover:text-red-300 transition-colors p-1"
|
||||||
|
aria-label="Close form"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Content */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{type === 'purchase' ? (
|
||||||
|
<AddPurchaseForm
|
||||||
|
onSuccess={() => {
|
||||||
|
if (onPurchaseSuccess) onPurchaseSuccess();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : type === 'milestone' ? (
|
||||||
|
<AddMilestoneForm
|
||||||
|
onSuccess={() => {
|
||||||
|
if (onMilestoneSuccess) onMilestoneSuccess();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UpdatePriceForm
|
||||||
|
onSuccess={() => {
|
||||||
|
if (onPriceSuccess) onPriceSuccess();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
resources/js/components/Display/LedDisplay.tsx
Normal file
60
resources/js/components/Display/LedDisplay.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full text-center select-none cursor-pointer",
|
||||||
|
"bg-black text-red-500",
|
||||||
|
"px-8 py-12 transition-all duration-300",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="relative w-full flex items-center justify-center">
|
||||||
|
<div className={cn(
|
||||||
|
"relative z-10",
|
||||||
|
"text-[8rem] md:text-[12rem] lg:text-[16rem]",
|
||||||
|
"font-digital font-normal",
|
||||||
|
"text-red-500",
|
||||||
|
"drop-shadow-[0_0_10px_rgba(239,68,68,0.8)]",
|
||||||
|
"filter brightness-110",
|
||||||
|
"leading-none",
|
||||||
|
"transition-all duration-300"
|
||||||
|
)}
|
||||||
|
style={{ letterSpacing: '0.15em' }}>
|
||||||
|
{formattedValue}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar';
|
||||||
import { UserInfo } from '@/components/user-info';
|
import { UserInfo } from '@/components/Settings/UserInfo';
|
||||||
import { UserMenuContent } from '@/components/user-menu-content';
|
import { UserMenuContent } from '@/components/Settings/UserMenuContent';
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
import { type SharedData } from '@/types';
|
import { type SharedData } from '@/types';
|
||||||
import { usePage } from '@inertiajs/react';
|
import { usePage } from '@inertiajs/react';
|
||||||
69
resources/js/components/Display/ProgressBar.tsx
Normal file
69
resources/js/components/Display/ProgressBar.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-black cursor-pointer",
|
||||||
|
"transition-all duration-300",
|
||||||
|
"p-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* Progress Bar Container */}
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Old-school progress bar with overlaid text */}
|
||||||
|
<div className="w-full border-4 border-red-500 p-2 bg-black relative overflow-hidden">
|
||||||
|
{/* Inner container */}
|
||||||
|
<div className="relative h-8">
|
||||||
|
{/* Progress fill */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 h-full bg-red-500 transition-all duration-500 ease-out"
|
||||||
|
style={{ width: `${progressPercentage}%` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Text overlay */}
|
||||||
|
{selectedMilestone && (
|
||||||
|
<div className="relative h-full flex items-center justify-center">
|
||||||
|
{/* Base text (red on black background) */}
|
||||||
|
<div className="text-red-500 font-mono text-sm font-bold mix-blend-difference relative z-10">
|
||||||
|
{progressPercentage.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
resources/js/components/Display/StatsBox.tsx
Normal file
214
resources/js/components/Display/StatsBox.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-black p-8",
|
||||||
|
"transition-all duration-300",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4">
|
||||||
|
{/* STATS Title and Current Price */}
|
||||||
|
<div className="flex justify-between items-center mb-6 relative">
|
||||||
|
<h2 className="text-red-500 text-lg font-mono font-bold tracking-wider">
|
||||||
|
STATS
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center space-x-2 relative">
|
||||||
|
{stats.currentPrice && (
|
||||||
|
<div className="text-red-500 text-sm font-mono tracking-wider">
|
||||||
|
VWCE: {formatCurrencyDetailed(stats.currentPrice)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Dropdown */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
|
className="flex items-center justify-center px-2 py-1 rounded border border-red-500/50 text-red-500 hover:bg-red-800/40 hover:text-red-300 transition-colors text-sm"
|
||||||
|
aria-label="Add actions"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="absolute top-full right-0 mt-2 bg-black border-2 border-red-500/50 rounded shadow-lg min-w-40 z-10">
|
||||||
|
{onAddPurchase && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onAddPurchase();
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
||||||
|
>
|
||||||
|
ADD PURCHASE
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onAddMilestone && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onAddMilestone();
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
||||||
|
>
|
||||||
|
ADD MILESTONE
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onUpdatePrice && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onUpdatePrice();
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
||||||
|
>
|
||||||
|
UPDATE PRICE
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestone Cycle Button */}
|
||||||
|
{milestones.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={handleCycleMilestone}
|
||||||
|
className="flex items-center justify-center px-2 py-1 rounded border border-red-500/50 text-red-500 hover:bg-red-800/40 hover:text-red-300 transition-colors text-sm"
|
||||||
|
aria-label="Cycle milestone"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestone Table */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<div className="text-red-500 underline font-bold mb-3 font-mono">MILESTONES</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm font-mono">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-red-500 text-xs py-2">DESCRIPTION</th>
|
||||||
|
<th className="text-right text-red-500 text-xs py-2">SHARES</th>
|
||||||
|
<th className="text-right text-red-500 text-xs py-2 pr-4">SWR 3%</th>
|
||||||
|
<th className="text-right text-red-500 text-xs py-2">SWR 4%</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{/* Current position row */}
|
||||||
|
<tr className="text-red-500 font-bold">
|
||||||
|
<td className="py-1 pr-4">CURRENT</td>
|
||||||
|
<td className="text-right py-1 pr-4">
|
||||||
|
{Math.floor(stats.totalShares).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1 pr-4">
|
||||||
|
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.03) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1">
|
||||||
|
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.04) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
isSelectedMilestone
|
||||||
|
? "text-red-500 font-bold"
|
||||||
|
: "bg-red-500 text-black"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="py-1 pr-4">
|
||||||
|
{milestone.description}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1 pr-4">
|
||||||
|
{Math.floor(milestone.target).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1 pr-4">
|
||||||
|
{stats.currentPrice ? formatCurrency(swr3) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1">
|
||||||
|
{stats.currentPrice ? formatCurrency(swr4) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
resources/js/components/Milestones/AddMilestoneForm.tsx
Normal file
82
resources/js/components/Milestones/AddMilestoneForm.tsx
Normal file
|
|
@ -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<MilestoneFormData>({
|
||||||
|
target: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit: FormEventHandler = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
post(route('milestones.store'), {
|
||||||
|
onSuccess: () => {
|
||||||
|
reset();
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="target" className="text-red-400">Target Number</Label>
|
||||||
|
<Input
|
||||||
|
id="target"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="1"
|
||||||
|
placeholder="1500"
|
||||||
|
value={data.target}
|
||||||
|
onChange={(e) => setData('target', e.target.value)}
|
||||||
|
className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30"
|
||||||
|
/>
|
||||||
|
<InputError message={errors.target} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description" className="text-red-400">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
type="text"
|
||||||
|
placeholder="First milestone"
|
||||||
|
value={data.description}
|
||||||
|
onChange={(e) => setData('description', e.target.value)}
|
||||||
|
className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30"
|
||||||
|
/>
|
||||||
|
<InputError message={errors.description} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={processing}
|
||||||
|
className="w-full bg-red-600 hover:bg-red-700 text-white border-red-500"
|
||||||
|
>
|
||||||
|
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Add Milestone
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
resources/js/components/Pricing/UpdatePriceForm.tsx
Normal file
93
resources/js/components/Pricing/UpdatePriceForm.tsx
Normal file
|
|
@ -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<PriceUpdateFormData>({
|
||||||
|
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 (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Update Asset Price</CardTitle>
|
||||||
|
{currentPrice && (
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
Current price: €{currentPrice.toFixed(4)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="date">Price Date</Label>
|
||||||
|
<Input
|
||||||
|
id="date"
|
||||||
|
type="date"
|
||||||
|
value={data.date}
|
||||||
|
onChange={(e) => setData('date', e.target.value)}
|
||||||
|
max={new Date().toISOString().split('T')[0]}
|
||||||
|
/>
|
||||||
|
<InputError message={errors.date} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="price">Asset Price (€)</Label>
|
||||||
|
<Input
|
||||||
|
id="price"
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
min="0"
|
||||||
|
placeholder="123.4567"
|
||||||
|
value={data.price}
|
||||||
|
onChange={(e) => setData('price', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
Price per unit/share of the asset
|
||||||
|
</p>
|
||||||
|
<InputError message={errors.price} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={processing}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Update Price
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Button } from '@/components/ui/button';
|
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 { useAppearance } from '@/hooks/use-appearance';
|
||||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { useForm } from '@inertiajs/react';
|
import { useForm } from '@inertiajs/react';
|
||||||
import { FormEventHandler, useRef } from 'react';
|
import { FormEventHandler, useRef } from 'react';
|
||||||
|
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/InputError';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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';
|
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
|
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/DropdownMenu';
|
||||||
import { UserInfo } from '@/components/user-info';
|
import { UserInfo } from '@/components/Settings/UserInfo';
|
||||||
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
|
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
|
||||||
import { type User } from '@/types';
|
import { type User } from '@/types';
|
||||||
import { Link, router } from '@inertiajs/react';
|
import { Link, router } from '@inertiajs/react';
|
||||||
133
resources/js/components/Transactions/AddPurchaseForm.tsx
Normal file
133
resources/js/components/Transactions/AddPurchaseForm.tsx
Normal file
|
|
@ -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<PurchaseFormData>({
|
||||||
|
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 (
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="date" className="text-red-400">Purchase Date</Label>
|
||||||
|
<Input
|
||||||
|
id="date"
|
||||||
|
type="date"
|
||||||
|
value={data.date}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<InputError message={errors.date} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="shares" className="text-red-400">Number of Shares</Label>
|
||||||
|
<Input
|
||||||
|
id="shares"
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
min="0"
|
||||||
|
placeholder="1.234567"
|
||||||
|
value={data.shares}
|
||||||
|
onChange={(e) => setData('shares', e.target.value)}
|
||||||
|
className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30"
|
||||||
|
/>
|
||||||
|
<InputError message={errors.shares} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="price_per_share" className="text-red-400">Price per Share (€)</Label>
|
||||||
|
<Input
|
||||||
|
id="price_per_share"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="123.45"
|
||||||
|
value={data.price_per_share}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<InputError message={errors.price_per_share} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="total_cost" className="text-red-400">Total Cost (€)</Label>
|
||||||
|
<Input
|
||||||
|
id="total_cost"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="1234.56"
|
||||||
|
value={data.total_cost}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-red-400/60 mt-1">
|
||||||
|
Auto-calculated from shares × price
|
||||||
|
</p>
|
||||||
|
<InputError message={errors.total_cost} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={processing}
|
||||||
|
className="w-full bg-red-600 hover:bg-red-700 text-white border-red-500"
|
||||||
|
>
|
||||||
|
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Add Purchase
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { AppContent } from '@/components/app-content';
|
import { AppContent } from '@/components/Display/AppContent';
|
||||||
import { AppHeader } from '@/components/app-header';
|
import { AppHeader } from '@/components/Display/AppHeader';
|
||||||
import { AppShell } from '@/components/app-shell';
|
import { AppShell } from '@/components/Display/AppShell';
|
||||||
import { type BreadcrumbItem } from '@/types';
|
import { type BreadcrumbItem } from '@/types';
|
||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { AppContent } from '@/components/app-content';
|
import { AppContent } from '@/components/Display/AppContent';
|
||||||
import { AppShell } from '@/components/app-shell';
|
import { AppShell } from '@/components/Display/AppShell';
|
||||||
import { AppSidebar } from '@/components/app-sidebar';
|
import { AppSidebar } from '@/components/Display/AppSidebar';
|
||||||
import { AppSidebarHeader } from '@/components/app-sidebar-header';
|
import { AppSidebarHeader } from '@/components/Display/AppSidebarHeader';
|
||||||
import { type BreadcrumbItem } from '@/types';
|
import { type BreadcrumbItem } from '@/types';
|
||||||
import { type PropsWithChildren } from 'react';
|
import { type PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import { type PropsWithChildren } from 'react';
|
import { type PropsWithChildren } from 'react';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import AppLogoIcon from '@/components/app-logo-icon';
|
import AppLogoIcon from '@/components/Display/AppLogoIcon';
|
||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import { type PropsWithChildren } from 'react';
|
import { type PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import AppLogoIcon from '@/components/app-logo-icon';
|
import AppLogoIcon from '@/components/Display/AppLogoIcon';
|
||||||
import { type SharedData } from '@/types';
|
import { type SharedData } from '@/types';
|
||||||
import { Link, usePage } from '@inertiajs/react';
|
import { Link, usePage } from '@inertiajs/react';
|
||||||
import { type PropsWithChildren } from 'react';
|
import { type PropsWithChildren } from 'react';
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Head, useForm } from '@inertiajs/react';
|
||||||
import { LoaderCircle } from 'lucide-react';
|
import { LoaderCircle } from 'lucide-react';
|
||||||
import { FormEventHandler } from 'react';
|
import { FormEventHandler } from 'react';
|
||||||
|
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/InputError';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { Head, useForm } from '@inertiajs/react';
|
||||||
import { LoaderCircle } from 'lucide-react';
|
import { LoaderCircle } from 'lucide-react';
|
||||||
import { FormEventHandler } from 'react';
|
import { FormEventHandler } from 'react';
|
||||||
|
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/InputError';
|
||||||
import TextLink from '@/components/text-link';
|
import TextLink from '@/components/TextLink';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { Head, useForm } from '@inertiajs/react';
|
||||||
import { LoaderCircle } from 'lucide-react';
|
import { LoaderCircle } from 'lucide-react';
|
||||||
import { FormEventHandler } from 'react';
|
import { FormEventHandler } from 'react';
|
||||||
|
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/InputError';
|
||||||
import TextLink from '@/components/text-link';
|
import TextLink from '@/components/TextLink';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { Head, useForm } from '@inertiajs/react';
|
||||||
import { LoaderCircle } from 'lucide-react';
|
import { LoaderCircle } from 'lucide-react';
|
||||||
import { FormEventHandler } from 'react';
|
import { FormEventHandler } from 'react';
|
||||||
|
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/InputError';
|
||||||
import TextLink from '@/components/text-link';
|
import TextLink from '@/components/TextLink';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Head, useForm } from '@inertiajs/react';
|
||||||
import { LoaderCircle } from 'lucide-react';
|
import { LoaderCircle } from 'lucide-react';
|
||||||
import { FormEventHandler } from 'react';
|
import { FormEventHandler } from 'react';
|
||||||
|
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/InputError';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Head, useForm } from '@inertiajs/react';
|
||||||
import { LoaderCircle } from 'lucide-react';
|
import { LoaderCircle } from 'lucide-react';
|
||||||
import { FormEventHandler } from 'react';
|
import { FormEventHandler } from 'react';
|
||||||
|
|
||||||
import TextLink from '@/components/text-link';
|
import TextLink from '@/components/TextLink';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import AuthLayout from '@/layouts/auth-layout';
|
import AuthLayout from '@/layouts/auth-layout';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,225 @@
|
||||||
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
|
import LedDisplay from '@/components/Display/LedDisplay';
|
||||||
import AppLayout from '@/layouts/app-layout';
|
import InlineForm from '@/components/Display/InlineForm';
|
||||||
import { type BreadcrumbItem } from '@/types';
|
import ProgressBar from '@/components/Display/ProgressBar';
|
||||||
|
import StatsBox from '@/components/Display/StatsBox';
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
interface PurchaseSummary {
|
||||||
{
|
total_shares: number;
|
||||||
title: 'Dashboard',
|
total_investment: number;
|
||||||
href: '/dashboard',
|
average_cost_per_share: number;
|
||||||
},
|
}
|
||||||
];
|
|
||||||
|
interface CurrentPrice {
|
||||||
|
current_price: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Milestone {
|
||||||
|
target: number;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
|
const [purchaseData, setPurchaseData] = useState<PurchaseSummary>({
|
||||||
|
total_shares: 0,
|
||||||
|
total_investment: 0,
|
||||||
|
average_cost_per_share: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [priceData, setPriceData] = useState<CurrentPrice>({
|
||||||
|
current_price: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||||
|
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 (
|
return (
|
||||||
<AppLayout breadcrumbs={breadcrumbs}>
|
<>
|
||||||
<Head title="Dashboard" />
|
<Head title="Dashboard" />
|
||||||
<div className="flex h-full flex-1 flex-col gap-4 rounded-xl p-4 overflow-x-auto">
|
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
<div className="text-red-500 font-mono text-lg animate-pulse">
|
||||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
LOADING...
|
||||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
|
||||||
</div>
|
|
||||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
|
||||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
|
||||||
</div>
|
|
||||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
|
||||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border">
|
</>
|
||||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<>
|
||||||
|
<Head title="VWCE Tracker" />
|
||||||
|
|
||||||
|
{/* Stacked Layout */}
|
||||||
|
<div className="min-h-screen bg-black">
|
||||||
|
<div className="w-full max-w-4xl mx-auto px-4">
|
||||||
|
{/* Box 1: LED Number Display - Fixed position from top */}
|
||||||
|
<div className="pt-32">
|
||||||
|
<LedDisplay
|
||||||
|
value={purchaseData.total_shares}
|
||||||
|
onClick={handleLedClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Box 2: Progress Bar (toggleable) */}
|
||||||
|
<div className="mt-4" style={{ display: showProgressBar ? 'block' : 'none' }}>
|
||||||
|
<ProgressBar
|
||||||
|
currentShares={purchaseData.total_shares}
|
||||||
|
milestones={milestones}
|
||||||
|
selectedMilestoneIndex={selectedMilestoneIndex}
|
||||||
|
onClick={handleProgressClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Box 3: Stats Box (toggleable) */}
|
||||||
|
<div className="mt-4" style={{ display: showStatsBox ? 'block' : 'none' }}>
|
||||||
|
<StatsBox
|
||||||
|
stats={statsData}
|
||||||
|
milestones={milestones}
|
||||||
|
selectedMilestoneIndex={selectedMilestoneIndex}
|
||||||
|
onMilestoneSelect={handleMilestoneSelect}
|
||||||
|
onAddPurchase={() => setActiveForm('purchase')}
|
||||||
|
onAddMilestone={() => setActiveForm('milestone')}
|
||||||
|
onUpdatePrice={() => setActiveForm('price')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Box 4: Forms (only when active form is set) */}
|
||||||
|
<div className="mt-4" style={{ display: activeForm ? 'block' : 'none' }}>
|
||||||
|
<InlineForm
|
||||||
|
type={activeForm}
|
||||||
|
onClose={() => setActiveForm(null)}
|
||||||
|
onPurchaseSuccess={handlePurchaseSuccess}
|
||||||
|
onMilestoneSuccess={handleMilestoneSuccess}
|
||||||
|
onPriceSuccess={handlePriceSuccess}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
|
|
||||||
import AppearanceTabs from '@/components/appearance-tabs';
|
import AppearanceTabs from '@/components/Settings/AppearanceTabs';
|
||||||
import HeadingSmall from '@/components/heading-small';
|
import HeadingSmall from '@/components/HeadingSmall';
|
||||||
import { type BreadcrumbItem } from '@/types';
|
import { type BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
import AppLayout from '@/layouts/app-layout';
|
import AppLayout from '@/layouts/app-layout';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/InputError';
|
||||||
import AppLayout from '@/layouts/app-layout';
|
import AppLayout from '@/layouts/app-layout';
|
||||||
import SettingsLayout from '@/layouts/settings/layout';
|
import SettingsLayout from '@/layouts/settings/layout';
|
||||||
import { type BreadcrumbItem } from '@/types';
|
import { type BreadcrumbItem } from '@/types';
|
||||||
|
|
@ -6,7 +6,7 @@ import { Transition } from '@headlessui/react';
|
||||||
import { Head, useForm } from '@inertiajs/react';
|
import { Head, useForm } from '@inertiajs/react';
|
||||||
import { FormEventHandler, useRef } from 'react';
|
import { FormEventHandler, useRef } from 'react';
|
||||||
|
|
||||||
import HeadingSmall from '@/components/heading-small';
|
import HeadingSmall from '@/components/HeadingSmall';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ import { Transition } from '@headlessui/react';
|
||||||
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||||
import { FormEventHandler } from 'react';
|
import { FormEventHandler } from 'react';
|
||||||
|
|
||||||
import DeleteUser from '@/components/delete-user';
|
import DeleteUser from '@/components/Settings/DeleteUser';
|
||||||
import HeadingSmall from '@/components/heading-small';
|
import HeadingSmall from '@/components/HeadingSmall';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/InputError';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,9 @@
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600|major-mono-display:400" rel="stylesheet" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/fonts/7segment.woff" as="font" type="font/woff" crossorigin>
|
||||||
|
|
||||||
@routes
|
@routes
|
||||||
@viteReactRefresh
|
@viteReactRefresh
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,39 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Transactions\PurchaseController;
|
||||||
|
use App\Http\Controllers\Pricing\PricingController;
|
||||||
|
use App\Http\Controllers\Milestones\MilestoneController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return Inertia::render('welcome');
|
return redirect('/dashboard');
|
||||||
})->name('home');
|
})->name('home');
|
||||||
|
|
||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
Route::get('dashboard', function () {
|
||||||
Route::get('dashboard', function () {
|
|
||||||
return Inertia::render('dashboard');
|
return Inertia::render('dashboard');
|
||||||
})->name('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';
|
require __DIR__.'/settings.php';
|
||||||
|
|
|
||||||
59
tests/Feature/MilestoneTest.php
Normal file
59
tests/Feature/MilestoneTest.php
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Milestone;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class MilestoneTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_can_create_milestone(): void
|
||||||
|
{
|
||||||
|
$milestone = Milestone::create([
|
||||||
|
'target' => 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue