Compare commits
8 commits
221a0f879d
...
ab957d34cf
| Author | SHA1 | Date | |
|---|---|---|---|
| ab957d34cf | |||
| bace06d993 | |||
| 195e316da5 | |||
| cdb1e268e4 | |||
| c73b634b44 | |||
| 14f5d34775 | |||
| e93ce7b342 | |||
| fa69d78afe |
13 changed files with 173 additions and 558 deletions
|
|
@ -32,9 +32,9 @@ jobs:
|
|||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
TAG="${{ github.ref_name }}"
|
||||
echo "tags=forge.lvl0.xyz/myrmidex/incr:${TAG},forge.lvl0.xyz/myrmidex/incr:latest" >> $GITHUB_OUTPUT
|
||||
echo "tags=forge.lvl0.xyz/lvl0/incr:${TAG},forge.lvl0.xyz/lvl0/incr:latest" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tags=forge.lvl0.xyz/myrmidex/incr:latest" >> $GITHUB_OUTPUT
|
||||
echo "tags=forge.lvl0.xyz/lvl0/incr:latest" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Build and push
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Models\Asset;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TrackerController extends Controller
|
||||
|
|
@ -16,10 +17,10 @@ public function show(): JsonResponse
|
|||
$tracker = User::default()->tracker;
|
||||
|
||||
if (! $tracker) {
|
||||
return response()->json(null);
|
||||
return response()->json(['exists' => false]);
|
||||
}
|
||||
|
||||
return response()->json($tracker->load('asset'));
|
||||
return response()->json(['exists' => true, 'tracker' => $tracker->load('asset')]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
|
|
@ -54,7 +55,7 @@ public function store(Request $request): JsonResponse
|
|||
return response()->json($tracker->load('asset'), 201);
|
||||
}
|
||||
|
||||
public function update(Request $request): JsonResponse
|
||||
public function update(Request $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'label' => 'sometimes|string|max:255',
|
||||
|
|
@ -67,7 +68,7 @@ public function update(Request $request): JsonResponse
|
|||
$tracker = User::default()->tracker;
|
||||
|
||||
if (! $tracker) {
|
||||
return response()->json(['error' => 'No tracker found.'], 404);
|
||||
return back()->withErrors(['tracker' => 'No tracker found.']);
|
||||
}
|
||||
|
||||
if (array_key_exists('symbol', $validated)) {
|
||||
|
|
@ -95,6 +96,6 @@ public function update(Request $request): JsonResponse
|
|||
|
||||
$tracker->update($update);
|
||||
|
||||
return response()->json($tracker->load('asset'));
|
||||
return back();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
<?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,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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('entries', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('tracker_id')->constrained()->cascadeOnDelete();
|
||||
$table->date('date');
|
||||
$table->decimal('quantity', 12, 6);
|
||||
$table->decimal('unit_price', 12, 4)->nullable();
|
||||
$table->decimal('total_cost', 12, 2)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['tracker_id', 'date']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('entries');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Rename table
|
||||
Schema::rename('purchases', 'entries');
|
||||
|
||||
Schema::table('entries', function (Blueprint $table) {
|
||||
// Rename columns
|
||||
$table->renameColumn('shares', 'quantity');
|
||||
$table->renameColumn('price_per_share', 'unit_price');
|
||||
|
||||
// Add tracker_id FK (nullable first so we can backfill)
|
||||
$table->foreignId('tracker_id')->nullable()->after('id')->constrained()->cascadeOnDelete();
|
||||
});
|
||||
|
||||
// Backfill tracker_id: assign all entries to the first tracker (single-user app)
|
||||
$trackerId = DB::table('trackers')->value('id');
|
||||
if ($trackerId) {
|
||||
DB::table('entries')->update(['tracker_id' => $trackerId]);
|
||||
}
|
||||
|
||||
// Make tracker_id non-nullable now that it's backfilled
|
||||
Schema::table('entries', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('tracker_id')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('entries', function (Blueprint $table) {
|
||||
$table->dropForeign(['tracker_id']);
|
||||
$table->dropColumn('tracker_id');
|
||||
});
|
||||
|
||||
Schema::table('entries', function (Blueprint $table) {
|
||||
$table->renameColumn('unit_price', 'price_per_share');
|
||||
$table->renameColumn('quantity', 'shares');
|
||||
});
|
||||
|
||||
Schema::rename('entries', 'purchases');
|
||||
}
|
||||
};
|
||||
|
|
@ -30,7 +30,7 @@ export default function AssetSetupForm({ onSuccess, onCancel }: AssetSetupFormPr
|
|||
try {
|
||||
const response = await fetch('/tracker');
|
||||
if (response.ok) {
|
||||
const tracker = await response.json();
|
||||
const { tracker } = await response.json();
|
||||
if (tracker?.asset) {
|
||||
setData({
|
||||
symbol: tracker.asset.symbol || '',
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import AddEntryForm from '@/components/Transactions/AddEntryForm';
|
||||
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
||||
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type FormType = 'purchase' | 'milestone' | 'price';
|
||||
type FormType = 'purchase' | 'milestone';
|
||||
|
||||
interface InlineFormProps {
|
||||
type: FormType | null;
|
||||
unit?: string;
|
||||
priceTrackingEnabled?: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: (type: FormType) => void;
|
||||
className?: string;
|
||||
|
|
@ -17,7 +15,6 @@ interface InlineFormProps {
|
|||
export default function InlineForm({
|
||||
type,
|
||||
unit = 'units',
|
||||
priceTrackingEnabled = false,
|
||||
onClose,
|
||||
onSuccess,
|
||||
className,
|
||||
|
|
@ -42,17 +39,11 @@ export default function InlineForm({
|
|||
{type === 'purchase' ? (
|
||||
<AddEntryForm
|
||||
unit={unit}
|
||||
priceTrackingEnabled={priceTrackingEnabled}
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
) : type === 'milestone' ? (
|
||||
<AddMilestoneForm
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
) : (
|
||||
<UpdatePriceForm
|
||||
<AddMilestoneForm
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,12 +7,6 @@ import type { Milestone } from '@/types/domain';
|
|||
interface StatsBoxProps {
|
||||
stats: {
|
||||
totalShares: number;
|
||||
totalInvestment: number;
|
||||
averageCostPerShare: number;
|
||||
currentPrice?: number;
|
||||
currentValue?: number;
|
||||
profitLoss?: number;
|
||||
profitLossPercentage?: number;
|
||||
};
|
||||
unit?: string;
|
||||
milestones?: Milestone[];
|
||||
|
|
@ -21,9 +15,6 @@ interface StatsBoxProps {
|
|||
className?: string;
|
||||
onAddPurchase?: () => void;
|
||||
onAddMilestone?: () => void;
|
||||
onUpdatePrice?: () => void;
|
||||
assetSymbol?: string;
|
||||
priceTrackingEnabled?: boolean;
|
||||
}
|
||||
|
||||
export default function StatsBox({
|
||||
|
|
@ -35,9 +26,6 @@ export default function StatsBox({
|
|||
className,
|
||||
onAddPurchase,
|
||||
onAddMilestone,
|
||||
onUpdatePrice,
|
||||
assetSymbol,
|
||||
priceTrackingEnabled = false,
|
||||
}: StatsBoxProps) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
|
|
@ -46,22 +34,6 @@ export default function StatsBox({
|
|||
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
|
||||
|
|
@ -72,28 +44,20 @@ export default function StatsBox({
|
|||
)}
|
||||
>
|
||||
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
|
||||
{/* STATS Title and Current Price */}
|
||||
<div className="flex justify-between items-center mb-6 relative">
|
||||
<ComponentTitle>Stats</ComponentTitle>
|
||||
|
||||
<div className="flex items-center space-x-2 relative">
|
||||
{priceTrackingEnabled && stats.currentPrice && (
|
||||
<div className="text-red-500 text-sm font-mono tracking-wider">
|
||||
{assetSymbol ?? 'PRICE'}: {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"
|
||||
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 && (
|
||||
|
|
@ -113,22 +77,11 @@ export default function StatsBox({
|
|||
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"
|
||||
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 MILESTONE
|
||||
</button>
|
||||
)}
|
||||
{priceTrackingEnabled && 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>
|
||||
|
|
@ -146,78 +99,45 @@ export default function StatsBox({
|
|||
</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">{unit.toUpperCase()}</th>
|
||||
{priceTrackingEnabled && <th className="text-right text-red-500 text-xs py-2 pr-4">SWR 3%</th>}
|
||||
{priceTrackingEnabled && <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>
|
||||
{priceTrackingEnabled && (
|
||||
<td className="text-right py-1 pr-4">
|
||||
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.03) : 'N/A'}
|
||||
</td>
|
||||
)}
|
||||
{priceTrackingEnabled && (
|
||||
{/* 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">{unit.toUpperCase()}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="text-red-500 font-bold">
|
||||
<td className="py-1 pr-4">CURRENT</td>
|
||||
<td className="text-right py-1">
|
||||
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.04) : 'N/A'}
|
||||
{Math.floor(stats.totalShares).toLocaleString()}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
</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 (
|
||||
{milestones.map((milestone, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className={cn(
|
||||
isSelectedMilestone
|
||||
index === selectedMilestoneIndex
|
||||
? "bg-red-500 text-black"
|
||||
: "text-red-500 font-bold"
|
||||
)}
|
||||
>
|
||||
<td className="py-1 pr-4">
|
||||
{milestone.description}
|
||||
</td>
|
||||
<td className="text-right py-1 pr-4">
|
||||
<td className="py-1 pr-4">{milestone.description}</td>
|
||||
<td className="text-right py-1">
|
||||
{Math.floor(milestone.target).toLocaleString()}
|
||||
</td>
|
||||
{priceTrackingEnabled && (
|
||||
<td className="text-right py-1 pr-4">
|
||||
{stats.currentPrice ? formatCurrency(swr3) : 'N/A'}
|
||||
</td>
|
||||
)}
|
||||
{priceTrackingEnabled && (
|
||||
<td className="text-right py-1">
|
||||
{stats.currentPrice ? formatCurrency(swr4) : 'N/A'}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,53 +2,55 @@ 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, useState } from 'react';
|
||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
||||
|
||||
interface TrackerFormData {
|
||||
label: string;
|
||||
unit: string;
|
||||
price_tracking_enabled: string;
|
||||
symbol: string;
|
||||
full_name: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface CreateTrackerStepProps {
|
||||
onSuccess: (priceTrackingEnabled: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function CreateTrackerStep({ onSuccess }: CreateTrackerStepProps) {
|
||||
const [priceTracking, setPriceTracking] = useState(false);
|
||||
const [label, setLabel] = useState('');
|
||||
const [unit, setUnit] = useState('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const { data, setData, post, processing, errors } = useForm<TrackerFormData>({
|
||||
label: '',
|
||||
unit: '',
|
||||
price_tracking_enabled: '0',
|
||||
symbol: '',
|
||||
full_name: '',
|
||||
});
|
||||
|
||||
const togglePriceTracking = (enabled: boolean) => {
|
||||
setPriceTracking(enabled);
|
||||
setData({
|
||||
...data,
|
||||
price_tracking_enabled: enabled ? '1' : '0',
|
||||
symbol: enabled ? data.symbol : '',
|
||||
full_name: enabled ? data.full_name : '',
|
||||
});
|
||||
};
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
const submit: FormEventHandler = async (e) => {
|
||||
e.preventDefault();
|
||||
setProcessing(true);
|
||||
setErrors({});
|
||||
|
||||
post(route('tracker.store'), {
|
||||
onSuccess: () => {
|
||||
onSuccess(priceTracking);
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(route('tracker.store'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
label,
|
||||
unit,
|
||||
price_tracking_enabled: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok || response.status === 201 || response.status === 409) {
|
||||
onSuccess();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
if (data.errors) {
|
||||
setErrors(data.errors);
|
||||
} else if (data.message) {
|
||||
setErrors({ label: data.message });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setErrors({ label: 'Something went wrong. Please try again.' });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -68,8 +70,8 @@ export default function CreateTrackerStep({ onSuccess }: CreateTrackerStepProps)
|
|||
id="label"
|
||||
type="text"
|
||||
placeholder="My Portfolio"
|
||||
value={data.label}
|
||||
onChange={(e) => setData('label', e.target.value)}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
|
||||
/>
|
||||
<p className="text-xs text-red-400/60 mt-1 font-mono">
|
||||
|
|
@ -86,8 +88,8 @@ export default function CreateTrackerStep({ onSuccess }: CreateTrackerStepProps)
|
|||
id="unit"
|
||||
type="text"
|
||||
placeholder="shares"
|
||||
value={data.unit}
|
||||
onChange={(e) => setData('unit', e.target.value)}
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
|
||||
/>
|
||||
<p className="text-xs text-red-400/60 mt-1 font-mono">
|
||||
|
|
@ -96,59 +98,9 @@ export default function CreateTrackerStep({ onSuccess }: CreateTrackerStepProps)
|
|||
<InputError message={errors.unit} />
|
||||
</div>
|
||||
|
||||
<div className="border border-red-500/30 p-4 space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={priceTracking}
|
||||
onChange={(e) => togglePriceTracking(e.target.checked)}
|
||||
className="w-4 h-4 accent-red-500"
|
||||
/>
|
||||
<span className="text-red-400 font-mono text-sm uppercase tracking-wider group-hover:text-red-300">
|
||||
Enable price tracking
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-red-400/60 font-mono text-xs">
|
||||
Track market price, portfolio value, and P&L. Requires an asset symbol.
|
||||
</p>
|
||||
|
||||
{priceTracking && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div>
|
||||
<Label htmlFor="symbol" className="text-red-400 font-mono text-xs uppercase tracking-wider">
|
||||
> Asset Symbol
|
||||
</Label>
|
||||
<Input
|
||||
id="symbol"
|
||||
type="text"
|
||||
placeholder="VWCE"
|
||||
value={data.symbol}
|
||||
onChange={(e) => setData('symbol', e.target.value.toUpperCase())}
|
||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
|
||||
/>
|
||||
<InputError message={errors.symbol} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="full_name" className="text-red-400 font-mono text-xs uppercase tracking-wider">
|
||||
> Full Name (Optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="full_name"
|
||||
type="text"
|
||||
placeholder="Vanguard FTSE All-World UCITS ETF"
|
||||
value={data.full_name}
|
||||
onChange={(e) => setData('full_name', e.target.value)}
|
||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
|
||||
/>
|
||||
<InputError message={errors.full_name} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing || !data.label || !data.unit || (priceTracking && !data.symbol)}
|
||||
disabled={processing || !label || !unit}
|
||||
className="w-full bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
||||
>
|
||||
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,8 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import AssetSetupForm from '@/components/Assets/AssetSetupForm';
|
||||
import AddEntryForm from '@/components/Transactions/AddEntryForm';
|
||||
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
||||
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
|
||||
import CreateTrackerStep from '@/components/Onboarding/CreateTrackerStep';
|
||||
|
||||
function PriceTrackingStep({ onEnable, onSkip }: { onEnable: () => void; onSkip?: () => void }) {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<label className="flex items-center gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={e => setEnabled(e.target.checked)}
|
||||
className="w-4 h-4 accent-red-500"
|
||||
/>
|
||||
<span className="text-red-400 font-mono text-sm uppercase tracking-wider group-hover:text-red-300">
|
||||
Enable price tracking (optional)
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-red-400/60 font-mono text-xs">
|
||||
Track the current market price of your asset to see portfolio value and P&L. You can enable this later in settings.
|
||||
</p>
|
||||
|
||||
{enabled && (
|
||||
<div className="border border-red-500/30 p-4">
|
||||
<UpdatePriceForm onSuccess={onEnable} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!enabled && (
|
||||
<button
|
||||
onClick={onSkip ?? (() => {})}
|
||||
className="w-full py-2 font-mono text-xs uppercase tracking-wider border border-red-500/50 text-red-400 hover:bg-red-950/30 hover:text-red-300 transition-colors"
|
||||
>
|
||||
Skip and finish
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OnboardingStep {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -51,13 +11,7 @@ interface OnboardingStep {
|
|||
required: boolean;
|
||||
}
|
||||
|
||||
const ASSET_STEPS: OnboardingStep[] = [
|
||||
{ id: 'entries', title: 'ADD ENTRIES', description: 'Enter your current holdings', completed: false, required: true },
|
||||
{ id: 'milestones', title: 'SET MILESTONES', description: 'Define your goals', completed: false, required: true },
|
||||
{ id: 'price', title: 'CURRENT PRICE', description: 'Set current asset price (optional)', completed: false, required: false },
|
||||
];
|
||||
|
||||
const SIMPLE_STEPS: OnboardingStep[] = [
|
||||
const STEPS: OnboardingStep[] = [
|
||||
{ id: 'entries', title: 'STARTING AMOUNT', description: 'Enter your starting amount', completed: false, required: true },
|
||||
{ id: 'milestones', title: 'SET MILESTONES', description: 'Define your goals', completed: false, required: true },
|
||||
];
|
||||
|
|
@ -68,28 +22,36 @@ interface OnboardingFlowProps {
|
|||
|
||||
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
||||
const [trackerCreated, setTrackerCreated] = useState(false);
|
||||
const [priceTracking, setPriceTracking] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [steps, setSteps] = useState<OnboardingStep[]>([]);
|
||||
|
||||
// On mount: check if a tracker already exists and skip step 1 if so
|
||||
useEffect(() => {
|
||||
fetch('/tracker')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data?.tracker) {
|
||||
setTrackerCreated(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const checkOnboardingStatus = useCallback(async (currentSteps: OnboardingStep[]) => {
|
||||
try {
|
||||
const [entriesData, milestonesData, priceData] = await Promise.all([
|
||||
const [entriesData, milestonesData] = await Promise.all([
|
||||
fetch('/entries/summary').then(r => r.json()),
|
||||
fetch('/milestones').then(r => r.json()),
|
||||
fetch('/pricing/current').then(r => r.json()),
|
||||
]);
|
||||
|
||||
const hasEntries = entriesData.total_quantity > 0;
|
||||
const hasMilestones = milestonesData.length > 0;
|
||||
const hasPrice = !!priceData.current_price;
|
||||
|
||||
const freshSteps = currentSteps.map(step => ({
|
||||
...step,
|
||||
completed:
|
||||
(step.id === 'entries' && hasEntries) ||
|
||||
(step.id === 'milestones' && hasMilestones) ||
|
||||
(step.id === 'price' && hasPrice),
|
||||
(step.id === 'milestones' && hasMilestones),
|
||||
}));
|
||||
|
||||
setSteps(freshSteps);
|
||||
|
|
@ -109,14 +71,12 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
|||
useEffect(() => {
|
||||
if (!trackerCreated) return;
|
||||
|
||||
const initialSteps = priceTracking ? ASSET_STEPS : SIMPLE_STEPS;
|
||||
setSteps(initialSteps);
|
||||
setSteps(STEPS);
|
||||
setCurrentStep(0);
|
||||
checkOnboardingStatus(initialSteps);
|
||||
}, [trackerCreated, priceTracking, checkOnboardingStatus]);
|
||||
checkOnboardingStatus(STEPS);
|
||||
}, [trackerCreated, checkOnboardingStatus]);
|
||||
|
||||
const handleTrackerCreated = (withPriceTracking: boolean) => {
|
||||
setPriceTracking(withPriceTracking);
|
||||
const handleTrackerCreated = () => {
|
||||
setTrackerCreated(true);
|
||||
};
|
||||
|
||||
|
|
@ -138,11 +98,9 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
|||
|
||||
switch (step.id) {
|
||||
case 'entries':
|
||||
return <AddEntryForm onSuccess={handleStepComplete} priceTrackingEnabled={priceTracking} />;
|
||||
return <AddEntryForm onSuccess={handleStepComplete} />;
|
||||
case 'milestones':
|
||||
return <AddMilestoneForm onSuccess={handleStepComplete} />;
|
||||
case 'price':
|
||||
return <PriceTrackingStep onEnable={handleStepComplete} onSkip={onComplete ?? (() => {})} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
@ -181,7 +139,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
|||
: 'bg-black text-red-400/60 hover:text-red-400 hover:border-red-400'
|
||||
} ${index > 0 ? 'ml-2' : ''}`}
|
||||
>
|
||||
{step.completed ? '[✓]' : step.required ? '[REQ]' : '[OPT]'} {step.title}
|
||||
{step.completed ? '[✓]' : '[REQ]'} {step.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -206,7 +164,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
|||
[STATUS] {steps.filter(s => s.completed).length}/{steps.length} STEPS COMPLETE
|
||||
</p>
|
||||
<p className="text-red-400/60 font-mono text-xs">
|
||||
{steps.filter(s => s.required && !s.completed).length} REQUIRED REMAINING
|
||||
{steps.filter(s => !s.completed).length} REQUIRED REMAINING
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,30 +11,23 @@ import ComponentTitle from '@/components/ui/ComponentTitle';
|
|||
interface EntryFormData {
|
||||
date: string;
|
||||
quantity: string;
|
||||
unit_price: string;
|
||||
total_cost: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface AddEntryFormProps {
|
||||
unit?: string;
|
||||
priceTrackingEnabled?: boolean;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
interface EntrySummary {
|
||||
total_quantity: number;
|
||||
total_cost: number;
|
||||
average_cost_per_unit: number;
|
||||
}
|
||||
|
||||
export default function AddEntryForm({ unit = 'units', priceTrackingEnabled = false, onSuccess, onCancel }: AddEntryFormProps) {
|
||||
export default function AddEntryForm({ unit = 'units', onSuccess, onCancel }: AddEntryFormProps) {
|
||||
const { data, setData, post, processing, errors, reset } = useForm<EntryFormData>({
|
||||
date: todayISO(),
|
||||
quantity: '',
|
||||
unit_price: '',
|
||||
total_cost: '',
|
||||
});
|
||||
|
||||
const [currentHoldings, setCurrentHoldings] = useState<EntrySummary | null>(null);
|
||||
|
|
@ -55,18 +48,6 @@ export default function AddEntryForm({ unit = 'units', priceTrackingEnabled = fa
|
|||
fetchSummary();
|
||||
}, []);
|
||||
|
||||
// Auto-calculate total cost when quantity or unit_price changes
|
||||
useEffect(() => {
|
||||
if (data.quantity && data.unit_price) {
|
||||
const quantity = parseFloat(data.quantity);
|
||||
const unitPrice = parseFloat(data.unit_price);
|
||||
|
||||
if (!isNaN(quantity) && !isNaN(unitPrice)) {
|
||||
setData('total_cost', (quantity * unitPrice).toFixed(2));
|
||||
}
|
||||
}
|
||||
}, [data.quantity, data.unit_price, setData]);
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
@ -86,7 +67,6 @@ export default function AddEntryForm({ unit = 'units', priceTrackingEnabled = fa
|
|||
{currentHoldings && currentHoldings.total_quantity > 0 && (
|
||||
<p className="text-sm text-red-400/60 font-mono">
|
||||
[CURRENT] {currentHoldings.total_quantity.toFixed(6)} {unit}
|
||||
{priceTrackingEnabled && ` • €${currentHoldings.total_cost.toFixed(2)} spent`}
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
|
|
@ -120,41 +100,6 @@ export default function AddEntryForm({ unit = 'units', priceTrackingEnabled = fa
|
|||
<InputError message={errors.quantity} />
|
||||
</div>
|
||||
|
||||
{priceTrackingEnabled && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="unit_price" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Price per {unit} (€)</Label>
|
||||
<Input
|
||||
id="unit_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="123.45"
|
||||
value={data.unit_price}
|
||||
onChange={(e) => setData('unit_price', e.target.value)}
|
||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
|
||||
/>
|
||||
<InputError message={errors.unit_price} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="total_cost" className="text-red-400 font-mono text-xs uppercase tracking-wider">> 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 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
|
||||
/>
|
||||
<p className="text-xs text-red-400/60 mt-1 font-mono">[AUTO-CALC] quantity × price</p>
|
||||
<InputError message={errors.total_cost} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -6,47 +6,28 @@ import OnboardingFlow from '@/components/Onboarding/OnboardingFlow';
|
|||
import TerminalSpinner from '@/components/ui/TerminalSpinner';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Milestone, Tracker, TrackerAsset } from '@/types/domain';
|
||||
import type { Milestone, Tracker } from '@/types/domain';
|
||||
|
||||
interface PurchaseSummary {
|
||||
interface EntrySummary {
|
||||
total_shares: number;
|
||||
total_investment: number;
|
||||
average_cost_per_share: number;
|
||||
}
|
||||
|
||||
interface CurrentPrice {
|
||||
current_price: number | null;
|
||||
}
|
||||
|
||||
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 [totalShares, setTotalShares] = useState(0);
|
||||
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 [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||
const [tracker, setTracker] = useState<Tracker | null>(null);
|
||||
const [currentAsset, setCurrentAsset] = useState<TrackerAsset | null>(null);
|
||||
const [priceTrackingEnabled, setPriceTrackingEnabled] = useState(false);
|
||||
|
||||
// Fetch entry summary, current price, milestones, and check onboarding
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [entriesResponse, priceResponse, milestonesResponse, trackerResponse] = await Promise.all([
|
||||
const [entriesResponse, milestonesResponse, trackerResponse] = await Promise.all([
|
||||
fetch('/entries/summary'),
|
||||
fetch('/pricing/current'),
|
||||
fetch('/milestones'),
|
||||
fetch('/tracker'),
|
||||
]);
|
||||
|
|
@ -56,19 +37,10 @@ export default function Dashboard() {
|
|||
|
||||
if (entriesResponse.ok) {
|
||||
const entries = await entriesResponse.json();
|
||||
setPurchaseData({
|
||||
total_shares: entries.total_quantity,
|
||||
total_investment: entries.total_cost,
|
||||
average_cost_per_share: entries.average_cost_per_unit,
|
||||
});
|
||||
setTotalShares(entries.total_quantity);
|
||||
totalQuantity = entries.total_quantity;
|
||||
}
|
||||
|
||||
if (priceResponse.ok) {
|
||||
const price = await priceResponse.json();
|
||||
setPriceData(price);
|
||||
}
|
||||
|
||||
if (milestonesResponse.ok) {
|
||||
const milestonesData = await milestonesResponse.json();
|
||||
setMilestones(milestonesData);
|
||||
|
|
@ -76,10 +48,8 @@ export default function Dashboard() {
|
|||
}
|
||||
|
||||
if (trackerResponse.ok) {
|
||||
const trackerData = await trackerResponse.json();
|
||||
setTracker(trackerData);
|
||||
setCurrentAsset(trackerData?.asset ?? null);
|
||||
setPriceTrackingEnabled(trackerData?.price_tracking_enabled ?? false);
|
||||
const { tracker: trackerData } = await trackerResponse.json();
|
||||
setTracker(trackerData ?? null);
|
||||
}
|
||||
|
||||
setNeedsOnboarding(totalQuantity === 0 || milestonesCount === 0);
|
||||
|
|
@ -93,31 +63,24 @@ export default function Dashboard() {
|
|||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Refresh data after successful entry
|
||||
const handlePurchaseSuccess = async () => {
|
||||
try {
|
||||
const entriesResponse = await fetch('/entries/summary');
|
||||
if (entriesResponse.ok) {
|
||||
const entries = await entriesResponse.json();
|
||||
setPurchaseData({
|
||||
total_shares: entries.total_quantity,
|
||||
total_investment: entries.total_cost,
|
||||
average_cost_per_share: entries.average_cost_per_unit,
|
||||
});
|
||||
setTotalShares(entries.total_quantity);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh entry 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) {
|
||||
|
|
@ -125,47 +88,39 @@ export default function Dashboard() {
|
|||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
const handleOnboardingComplete = useCallback(async () => {
|
||||
const [entriesResponse, milestonesResponse, trackerResponse] = await Promise.all([
|
||||
fetch('/entries/summary'),
|
||||
fetch('/milestones'),
|
||||
fetch('/tracker'),
|
||||
]);
|
||||
|
||||
let totalQuantity = 0;
|
||||
let milestonesCount = 0;
|
||||
|
||||
if (entriesResponse.ok) {
|
||||
const entries = await entriesResponse.json();
|
||||
setTotalShares(entries.total_quantity);
|
||||
totalQuantity = entries.total_quantity;
|
||||
}
|
||||
};
|
||||
|
||||
if (milestonesResponse.ok) {
|
||||
const milestonesData = await milestonesResponse.json();
|
||||
setMilestones(milestonesData);
|
||||
milestonesCount = milestonesData.length;
|
||||
}
|
||||
|
||||
// Calculate portfolio stats
|
||||
const currentValue = priceData.current_price
|
||||
? purchaseData.total_shares * priceData.current_price
|
||||
: undefined;
|
||||
if (trackerResponse.ok) {
|
||||
const { tracker: trackerData } = await trackerResponse.json();
|
||||
setTracker(trackerData ?? null);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
setNeedsOnboarding(totalQuantity === 0 || milestonesCount === 0);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -176,64 +131,19 @@ export default function Dashboard() {
|
|||
);
|
||||
}
|
||||
|
||||
// Toggle handlers with cascading behavior
|
||||
const handleLedClick = () => {
|
||||
const newShowProgressBar = !showProgressBar;
|
||||
setShowProgressBar(newShowProgressBar);
|
||||
if (!newShowProgressBar) {
|
||||
// If hiding progress bar, also hide stats box
|
||||
setShowStatsBox(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProgressClick = () => {
|
||||
setShowStatsBox(!showStatsBox);
|
||||
setActiveForm(null)
|
||||
setActiveForm(null);
|
||||
};
|
||||
|
||||
// Handle onboarding completion
|
||||
const handleOnboardingComplete = useCallback(async () => {
|
||||
const [entriesResponse, priceResponse, milestonesResponse, trackerResponse] = await Promise.all([
|
||||
fetch('/entries/summary'),
|
||||
fetch('/pricing/current'),
|
||||
fetch('/milestones'),
|
||||
fetch('/tracker'),
|
||||
]);
|
||||
|
||||
let totalQuantity = 0;
|
||||
let milestonesCount = 0;
|
||||
|
||||
if (entriesResponse.ok) {
|
||||
const entries = await entriesResponse.json();
|
||||
setPurchaseData({
|
||||
total_shares: entries.total_quantity,
|
||||
total_investment: entries.total_cost,
|
||||
average_cost_per_share: entries.average_cost_per_unit,
|
||||
});
|
||||
totalQuantity = entries.total_quantity;
|
||||
}
|
||||
|
||||
if (priceResponse.ok) {
|
||||
const price = await priceResponse.json();
|
||||
setPriceData(price);
|
||||
}
|
||||
|
||||
if (milestonesResponse.ok) {
|
||||
const milestonesData = await milestonesResponse.json();
|
||||
setMilestones(milestonesData);
|
||||
milestonesCount = milestonesData.length;
|
||||
}
|
||||
|
||||
if (trackerResponse.ok) {
|
||||
const tracker = await trackerResponse.json();
|
||||
setCurrentAsset(tracker?.asset ?? null);
|
||||
setPriceTrackingEnabled(tracker?.price_tracking_enabled ?? false);
|
||||
}
|
||||
|
||||
setNeedsOnboarding(totalQuantity === 0 || milestonesCount === 0);
|
||||
}, []);
|
||||
|
||||
// Show onboarding if needed
|
||||
if (needsOnboarding) {
|
||||
return (
|
||||
<>
|
||||
|
|
@ -247,55 +157,45 @@ export default function Dashboard() {
|
|||
<>
|
||||
<Head title="incr" />
|
||||
|
||||
{/* 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}
|
||||
value={totalShares}
|
||||
unit={tracker?.unit}
|
||||
onClick={handleLedClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Box 2: Progress Bar (toggleable) */}
|
||||
<div style={{ display: showProgressBar ? 'block' : 'none' }}>
|
||||
<ProgressBar
|
||||
currentQuantity={purchaseData.total_shares}
|
||||
currentQuantity={totalShares}
|
||||
milestones={milestones}
|
||||
selectedMilestoneIndex={selectedMilestoneIndex}
|
||||
onClick={handleProgressClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Box 3: Stats Box (toggleable) */}
|
||||
<div style={{ display: showStatsBox ? 'block' : 'none' }}>
|
||||
<StatsBox
|
||||
stats={statsData}
|
||||
stats={{ totalShares }}
|
||||
unit={tracker?.unit}
|
||||
milestones={milestones}
|
||||
selectedMilestoneIndex={selectedMilestoneIndex}
|
||||
onMilestoneSelect={handleMilestoneSelect}
|
||||
onAddPurchase={() => setActiveForm('purchase')}
|
||||
onAddMilestone={() => setActiveForm('milestone')}
|
||||
onUpdatePrice={() => setActiveForm('price')}
|
||||
assetSymbol={currentAsset?.symbol}
|
||||
priceTrackingEnabled={priceTrackingEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Box 4: Forms (only when active form is set) */}
|
||||
<div style={{ display: activeForm && showProgressBar && showStatsBox ? 'block' : 'none' }}>
|
||||
<InlineForm
|
||||
type={activeForm}
|
||||
unit={tracker?.unit}
|
||||
priceTrackingEnabled={priceTrackingEnabled}
|
||||
onClose={() => setActiveForm(null)}
|
||||
onSuccess={(type) => {
|
||||
if (type === 'purchase') handlePurchaseSuccess();
|
||||
else if (type === 'milestone') handleMilestoneSuccess();
|
||||
else if (type === 'price') handlePriceSuccess();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
</style>
|
||||
|
||||
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
|
|
@ -43,7 +44,7 @@
|
|||
|
||||
@routes
|
||||
@viteReactRefresh
|
||||
@vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
|
||||
@vite(['resources/js/app.tsx'])
|
||||
@inertiaHead
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
|
|
|
|||
Loading…
Reference in a new issue