Fix milestone issues
This commit is contained in:
parent
2ea5107106
commit
6e08bd80ed
8 changed files with 193 additions and 58 deletions
|
|
@ -3,40 +3,32 @@
|
||||||
namespace App\Http\Controllers\Milestones;
|
namespace App\Http\Controllers\Milestones;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Milestone;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class MilestoneController extends Controller
|
class MilestoneController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
public function store(Request $request): RedirectResponse
|
||||||
* Store a new milestone.
|
|
||||||
*/
|
|
||||||
public function store(Request $request): JsonResponse
|
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'target' => 'required|integer|min:1',
|
'target' => 'required|integer|min:1',
|
||||||
'description' => 'required|string|max:255',
|
'description' => 'required|string|max:255',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// For now, just return success without persisting to database
|
Milestone::create([
|
||||||
// This allows the frontend form to work properly
|
'target' => $request->target,
|
||||||
return response()->json([
|
'description' => $request->description,
|
||||||
'message' => 'Milestone created successfully',
|
]);
|
||||||
'milestone' => [
|
|
||||||
'target' => $request->target,
|
return back()->with('success', 'Milestone created successfully');
|
||||||
'description' => $request->description,
|
|
||||||
'created_at' => now(),
|
|
||||||
]
|
|
||||||
], 201);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all milestones.
|
|
||||||
*/
|
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
// For now, return empty array
|
$milestones = Milestone::orderBy('target')->get();
|
||||||
// Later this could fetch from database
|
|
||||||
return response()->json([]);
|
return response()->json($milestones);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,45 +1,14 @@
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
interface ProgressBarProps {
|
interface ProgressBarProps {
|
||||||
value: number;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressBar({
|
export default function ProgressBar({
|
||||||
value,
|
|
||||||
className,
|
className,
|
||||||
onClick
|
onClick
|
||||||
}: ProgressBarProps) {
|
}: ProgressBarProps) {
|
||||||
const [currentMilestoneIndex, setCurrentMilestoneIndex] = useState(0);
|
|
||||||
|
|
||||||
// Milestone definitions
|
|
||||||
const milestones = [
|
|
||||||
{ target: 1500, label: '1.5K', color: 'bg-blue-500' },
|
|
||||||
{ target: 3000, label: '3K', color: 'bg-green-500' },
|
|
||||||
{ target: 4500, label: '4.5K', color: 'bg-yellow-500' },
|
|
||||||
{ target: 6000, label: '6K', color: 'bg-red-500' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const currentMilestone = milestones[currentMilestoneIndex];
|
|
||||||
const progress = Math.min((value / currentMilestone.target) * 100, 100);
|
|
||||||
const isCompleted = value >= currentMilestone.target;
|
|
||||||
|
|
||||||
// Milestone navigation
|
|
||||||
const nextMilestone = () => {
|
|
||||||
setCurrentMilestoneIndex((prev) =>
|
|
||||||
prev < milestones.length - 1 ? prev + 1 : 0
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevMilestone = () => {
|
|
||||||
setCurrentMilestoneIndex((prev) =>
|
|
||||||
prev > 0 ? prev - 1 : milestones.length - 1
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Milestone {
|
||||||
|
target: number;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface StatsBoxProps {
|
interface StatsBoxProps {
|
||||||
stats: {
|
stats: {
|
||||||
totalShares: number;
|
totalShares: number;
|
||||||
|
|
@ -11,6 +17,7 @@ interface StatsBoxProps {
|
||||||
profitLoss?: number;
|
profitLoss?: number;
|
||||||
profitLossPercentage?: number;
|
profitLossPercentage?: number;
|
||||||
};
|
};
|
||||||
|
milestones?: Milestone[];
|
||||||
className?: string;
|
className?: string;
|
||||||
onAddPurchase?: () => void;
|
onAddPurchase?: () => void;
|
||||||
onAddMilestone?: () => void;
|
onAddMilestone?: () => void;
|
||||||
|
|
@ -18,6 +25,7 @@ interface StatsBoxProps {
|
||||||
|
|
||||||
export default function StatsBox({
|
export default function StatsBox({
|
||||||
stats,
|
stats,
|
||||||
|
milestones = [],
|
||||||
className,
|
className,
|
||||||
onAddPurchase,
|
onAddPurchase,
|
||||||
onAddMilestone
|
onAddMilestone
|
||||||
|
|
@ -123,6 +131,40 @@ export default function StatsBox({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Milestones */}
|
||||||
|
{milestones.length > 0 && (
|
||||||
|
<div className="border-t border-red-500/30 pt-4">
|
||||||
|
<div className="text-red-400/70 text-xs mb-3">Milestones</div>
|
||||||
|
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||||
|
{milestones.map((milestone, index) => {
|
||||||
|
const isReached = stats.totalShares >= milestone.target;
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className={cn(
|
||||||
|
"w-2 h-2 rounded-full",
|
||||||
|
isReached ? "bg-green-400" : "bg-gray-600"
|
||||||
|
)} />
|
||||||
|
<span className={cn(
|
||||||
|
"font-mono",
|
||||||
|
isReached ? "text-green-400" : "text-red-400"
|
||||||
|
)}>
|
||||||
|
{milestone.target.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"text-xs truncate ml-2",
|
||||||
|
isReached ? "text-green-400/70" : "text-red-400/70"
|
||||||
|
)}>
|
||||||
|
{milestone.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="border-t border-red-500/30 pt-4">
|
<div className="border-t border-red-500/30 pt-4">
|
||||||
<div className="flex items-center justify-center space-x-4">
|
<div className="flex items-center justify-center space-x-4">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
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 InputError from '@/components/InputError';
|
import InputError from '@/components/InputError';
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ interface CurrentPrice {
|
||||||
current_price: number | null;
|
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>({
|
const [purchaseData, setPurchaseData] = useState<PurchaseSummary>({
|
||||||
total_shares: 0,
|
total_shares: 0,
|
||||||
|
|
@ -26,18 +32,20 @@ export default function Dashboard() {
|
||||||
current_price: null,
|
current_price: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||||
const [showProgressBar, setShowProgressBar] = useState(false);
|
const [showProgressBar, setShowProgressBar] = useState(false);
|
||||||
const [showStatsBox, setShowStatsBox] = useState(false);
|
const [showStatsBox, setShowStatsBox] = useState(false);
|
||||||
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | null>(null);
|
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Fetch purchase summary and current price
|
// Fetch purchase summary, current price, and milestones
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [purchaseResponse, priceResponse] = await Promise.all([
|
const [purchaseResponse, priceResponse, milestonesResponse] = await Promise.all([
|
||||||
fetch('/purchases/summary'),
|
fetch('/purchases/summary'),
|
||||||
fetch('/pricing/current'),
|
fetch('/pricing/current'),
|
||||||
|
fetch('/milestones'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (purchaseResponse.ok) {
|
if (purchaseResponse.ok) {
|
||||||
|
|
@ -49,6 +57,11 @@ export default function Dashboard() {
|
||||||
const price = await priceResponse.json();
|
const price = await priceResponse.json();
|
||||||
setPriceData(price);
|
setPriceData(price);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (milestonesResponse.ok) {
|
||||||
|
const milestonesData = await milestonesResponse.json();
|
||||||
|
setMilestones(milestonesData);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch data:', error);
|
console.error('Failed to fetch data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -72,6 +85,19 @@ export default function Dashboard() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Refresh milestones after successful creation
|
||||||
|
const handleMilestoneSuccess = async () => {
|
||||||
|
try {
|
||||||
|
const milestonesResponse = await fetch('/milestones');
|
||||||
|
if (milestonesResponse.ok) {
|
||||||
|
const milestonesData = await milestonesResponse.json();
|
||||||
|
setMilestones(milestonesData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh milestone data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// Calculate portfolio stats
|
// Calculate portfolio stats
|
||||||
const currentValue = priceData.current_price
|
const currentValue = priceData.current_price
|
||||||
|
|
@ -141,7 +167,6 @@ export default function Dashboard() {
|
||||||
{/* Box 2: Progress Bar (toggleable) */}
|
{/* Box 2: Progress Bar (toggleable) */}
|
||||||
<div className="mt-4" style={{ display: showProgressBar ? 'block' : 'none' }}>
|
<div className="mt-4" style={{ display: showProgressBar ? 'block' : 'none' }}>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
value={purchaseData.total_shares}
|
|
||||||
onClick={handleProgressClick}
|
onClick={handleProgressClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -150,6 +175,7 @@ export default function Dashboard() {
|
||||||
<div className="mt-4" style={{ display: showStatsBox ? 'block' : 'none' }}>
|
<div className="mt-4" style={{ display: showStatsBox ? 'block' : 'none' }}>
|
||||||
<StatsBox
|
<StatsBox
|
||||||
stats={statsData}
|
stats={statsData}
|
||||||
|
milestones={milestones}
|
||||||
onAddPurchase={() => setActiveForm('purchase')}
|
onAddPurchase={() => setActiveForm('purchase')}
|
||||||
onAddMilestone={() => setActiveForm('milestone')}
|
onAddMilestone={() => setActiveForm('milestone')}
|
||||||
/>
|
/>
|
||||||
|
|
@ -161,9 +187,7 @@ export default function Dashboard() {
|
||||||
type={activeForm}
|
type={activeForm}
|
||||||
onClose={() => setActiveForm(null)}
|
onClose={() => setActiveForm(null)}
|
||||||
onPurchaseSuccess={handlePurchaseSuccess}
|
onPurchaseSuccess={handlePurchaseSuccess}
|
||||||
onMilestoneSuccess={() => {
|
onMilestoneSuccess={handleMilestoneSuccess}
|
||||||
console.log('Milestone added successfully');
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
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