release/0.4.0 #60
14 changed files with 48 additions and 574 deletions
|
|
@ -1,43 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Milestones;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class MilestoneController extends Controller
|
|
||||||
{
|
|
||||||
public function store(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'target' => 'required|integer|min:1',
|
|
||||||
'description' => 'required|string|max:255',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tracker = User::default()->tracker;
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return back()->withErrors(['tracker' => 'No tracker found. Please complete onboarding first.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tracker->milestones()->create($validated);
|
|
||||||
|
|
||||||
return back()->with('success', 'Milestone created successfully');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index(): JsonResponse
|
|
||||||
{
|
|
||||||
$tracker = User::default()->tracker;
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return response()->json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json($tracker->milestones()->orderBy('target')->get());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @method static create(array $array)
|
|
||||||
* @method static orderBy(string $string)
|
|
||||||
*/
|
|
||||||
class Milestone extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = [
|
|
||||||
'tracker_id',
|
|
||||||
'target',
|
|
||||||
'description',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'target' => 'integer',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function tracker(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tracker::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -40,9 +40,4 @@ public function entries(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Entry::class);
|
return $this->hasMany(Entry::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function milestones(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Milestone::class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,18 +47,8 @@ public static function default(): self
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasCompletedOnboarding(): bool
|
|
||||||
{
|
|
||||||
return $this->hasEntries() && $this->hasMilestones();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasEntries(): bool
|
public function hasEntries(): bool
|
||||||
{
|
{
|
||||||
return (bool) $this->tracker?->entries()->exists();
|
return (bool) $this->tracker?->entries()->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasMilestones(): bool
|
|
||||||
{
|
|
||||||
return (bool) $this->tracker?->milestones()->exists();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?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::dropIfExists('milestones');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::create('milestones', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tracker_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->integer('target');
|
||||||
|
$table->string('description');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,28 +1,25 @@
|
||||||
import AddEntryForm from '@/components/Transactions/AddEntryForm';
|
import AddEntryForm from '@/components/Transactions/AddEntryForm';
|
||||||
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type FormType = 'purchase' | 'milestone';
|
|
||||||
|
|
||||||
interface InlineFormProps {
|
interface InlineFormProps {
|
||||||
type: FormType | null;
|
open: boolean;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSuccess?: (type: FormType) => void;
|
onSuccess?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InlineForm({
|
export default function InlineForm({
|
||||||
type,
|
open,
|
||||||
unit = 'units',
|
unit = 'units',
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
className,
|
className,
|
||||||
}: InlineFormProps) {
|
}: InlineFormProps) {
|
||||||
if (!type) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const handleSuccess = () => {
|
const handleSuccess = () => {
|
||||||
if (onSuccess) onSuccess(type);
|
onSuccess?.();
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -36,18 +33,11 @@ export default function InlineForm({
|
||||||
>
|
>
|
||||||
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
|
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
{type === 'purchase' ? (
|
<AddEntryForm
|
||||||
<AddEntryForm
|
unit={unit}
|
||||||
unit={unit}
|
onSuccess={handleSuccess}
|
||||||
onSuccess={handleSuccess}
|
onCancel={onClose}
|
||||||
onCancel={onClose}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<AddMilestoneForm
|
|
||||||
onSuccess={handleSuccess}
|
|
||||||
onCancel={onClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { Milestone } from '@/types/domain';
|
|
||||||
|
|
||||||
interface ProgressBarProps {
|
|
||||||
currentQuantity: number;
|
|
||||||
milestones: Milestone[];
|
|
||||||
selectedMilestoneIndex?: number;
|
|
||||||
className?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProgressBar({
|
|
||||||
currentQuantity,
|
|
||||||
milestones,
|
|
||||||
selectedMilestoneIndex = 0,
|
|
||||||
className,
|
|
||||||
onClick
|
|
||||||
}: ProgressBarProps) {
|
|
||||||
const selectedMilestone = milestones.length > 0 && selectedMilestoneIndex < milestones.length
|
|
||||||
? milestones[selectedMilestoneIndex]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const progressPercentage = selectedMilestone
|
|
||||||
? Math.min((currentQuantity / 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 glow-red">
|
|
||||||
{/* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Plus, ChevronRight } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
|
||||||
import type { Milestone } from '@/types/domain';
|
|
||||||
|
|
||||||
interface StatsBoxProps {
|
|
||||||
stats: {
|
|
||||||
totalShares: number;
|
|
||||||
};
|
|
||||||
unit?: string;
|
|
||||||
milestones?: Milestone[];
|
|
||||||
selectedMilestoneIndex?: number;
|
|
||||||
onMilestoneSelect?: (index: number) => void;
|
|
||||||
className?: string;
|
|
||||||
onAddPurchase?: () => void;
|
|
||||||
onAddMilestone?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StatsBox({
|
|
||||||
stats,
|
|
||||||
unit = 'units',
|
|
||||||
milestones = [],
|
|
||||||
selectedMilestoneIndex = 0,
|
|
||||||
onMilestoneSelect,
|
|
||||||
className,
|
|
||||||
onAddPurchase,
|
|
||||||
onAddMilestone,
|
|
||||||
}: StatsBoxProps) {
|
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleCycleMilestone = () => {
|
|
||||||
if (milestones.length === 0 || !onMilestoneSelect) return;
|
|
||||||
const nextIndex = (selectedMilestoneIndex + 1) % milestones.length;
|
|
||||||
onMilestoneSelect(nextIndex);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 glow-red">
|
|
||||||
<div className="flex justify-between items-center mb-6 relative">
|
|
||||||
<ComponentTitle>Stats</ComponentTitle>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 relative">
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
{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 ENTRY
|
|
||||||
</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 text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
|
||||||
>
|
|
||||||
ADD MILESTONE
|
|
||||||
</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">{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">
|
|
||||||
{Math.floor(stats.totalShares).toLocaleString()}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{milestones.map((milestone, index) => (
|
|
||||||
<tr
|
|
||||||
key={index}
|
|
||||||
className={cn(
|
|
||||||
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">
|
|
||||||
{Math.floor(milestone.target).toLocaleString()}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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';
|
|
||||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
|
||||||
|
|
||||||
interface MilestoneFormData {
|
|
||||||
target: string;
|
|
||||||
description: string;
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AddMilestoneFormProps {
|
|
||||||
onSuccess?: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddMilestoneForm({ onSuccess, onCancel }: 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">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<ComponentTitle>ADD MILESTONE</ComponentTitle>
|
|
||||||
<form onSubmit={submit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="target" className="text-red-400 font-mono text-xs uppercase tracking-wider">> 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 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.target} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="description" className="text-red-400 font-mono text-xs uppercase tracking-wider">> 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 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.description} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={processing}
|
|
||||||
className="flex-1 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" />}
|
|
||||||
[EXECUTE]
|
|
||||||
</Button>
|
|
||||||
{onCancel && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
[ABORT]
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +1,21 @@
|
||||||
import LedDisplay from '@/components/Display/LedDisplay';
|
import LedDisplay from '@/components/Display/LedDisplay';
|
||||||
import InlineForm from '@/components/Display/InlineForm';
|
import InlineForm from '@/components/Display/InlineForm';
|
||||||
import ProgressBar from '@/components/Display/ProgressBar';
|
|
||||||
import StatsBox from '@/components/Display/StatsBox';
|
|
||||||
import OnboardingFlow from '@/components/Onboarding/OnboardingFlow';
|
import OnboardingFlow from '@/components/Onboarding/OnboardingFlow';
|
||||||
import TerminalSpinner from '@/components/ui/TerminalSpinner';
|
import TerminalSpinner from '@/components/ui/TerminalSpinner';
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import type { Milestone, Tracker } from '@/types/domain';
|
import type { Tracker } from '@/types/domain';
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [totalShares, setTotalShares] = useState(0);
|
const [totalShares, setTotalShares] = useState(0);
|
||||||
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [selectedMilestoneIndex, setSelectedMilestoneIndex] = useState(0);
|
|
||||||
const [showProgressBar, setShowProgressBar] = useState(false);
|
|
||||||
const [showStatsBox, setShowStatsBox] = useState(false);
|
|
||||||
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||||
const [tracker, setTracker] = useState<Tracker | null>(null);
|
const [tracker, setTracker] = useState<Tracker | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
const [entriesResponse, milestonesResponse, trackerResponse] = await Promise.all([
|
const [entriesResponse, trackerResponse] = await Promise.all([
|
||||||
fetch('/entries/summary'),
|
fetch('/entries/summary'),
|
||||||
fetch('/milestones'),
|
|
||||||
fetch('/tracker'),
|
fetch('/tracker'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -31,10 +24,6 @@ export default function Dashboard() {
|
||||||
setTotalShares(entries.total_quantity);
|
setTotalShares(entries.total_quantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (milestonesResponse.ok) {
|
|
||||||
setMilestones(await milestonesResponse.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
let trackerExists = false;
|
let trackerExists = false;
|
||||||
|
|
||||||
if (trackerResponse.ok) {
|
if (trackerResponse.ok) {
|
||||||
|
|
@ -60,7 +49,7 @@ export default function Dashboard() {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
const handlePurchaseSuccess = async () => {
|
const handleEntrySuccess = async () => {
|
||||||
try {
|
try {
|
||||||
const entriesResponse = await fetch('/entries/summary');
|
const entriesResponse = await fetch('/entries/summary');
|
||||||
if (entriesResponse.ok) {
|
if (entriesResponse.ok) {
|
||||||
|
|
@ -72,23 +61,6 @@ export default function Dashboard() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMilestoneSuccess = async () => {
|
|
||||||
try {
|
|
||||||
const milestonesResponse = await fetch('/milestones');
|
|
||||||
if (milestonesResponse.ok) {
|
|
||||||
const milestonesData = await milestonesResponse.json();
|
|
||||||
setMilestones(milestonesData);
|
|
||||||
setSelectedMilestoneIndex(0);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to refresh milestone data:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMilestoneSelect = (index: number) => {
|
|
||||||
setSelectedMilestoneIndex(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOnboardingComplete = loadData;
|
const handleOnboardingComplete = loadData;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -100,23 +72,10 @@ export default function Dashboard() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLedClick = () => {
|
|
||||||
const newShowProgressBar = !showProgressBar;
|
|
||||||
setShowProgressBar(newShowProgressBar);
|
|
||||||
if (!newShowProgressBar) {
|
|
||||||
setShowStatsBox(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProgressClick = () => {
|
|
||||||
setShowStatsBox(!showStatsBox);
|
|
||||||
setActiveForm(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (needsOnboarding) {
|
if (needsOnboarding) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Asset Tracker - Setup" />
|
<Head title="incr - Setup" />
|
||||||
<OnboardingFlow onComplete={handleOnboardingComplete} />
|
<OnboardingFlow onComplete={handleOnboardingComplete} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -131,42 +90,16 @@ export default function Dashboard() {
|
||||||
<div className="pt-32">
|
<div className="pt-32">
|
||||||
<LedDisplay
|
<LedDisplay
|
||||||
value={totalShares}
|
value={totalShares}
|
||||||
onClick={handleLedClick}
|
onClick={() => setFormOpen((open) => !open)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: showProgressBar ? 'block' : 'none' }}>
|
<InlineForm
|
||||||
<ProgressBar
|
open={formOpen}
|
||||||
currentQuantity={totalShares}
|
unit={tracker?.unit}
|
||||||
milestones={milestones}
|
onClose={() => setFormOpen(false)}
|
||||||
selectedMilestoneIndex={selectedMilestoneIndex}
|
onSuccess={handleEntrySuccess}
|
||||||
onClick={handleProgressClick}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: showStatsBox ? 'block' : 'none' }}>
|
|
||||||
<StatsBox
|
|
||||||
stats={{ totalShares }}
|
|
||||||
unit={tracker?.unit}
|
|
||||||
milestones={milestones}
|
|
||||||
selectedMilestoneIndex={selectedMilestoneIndex}
|
|
||||||
onMilestoneSelect={handleMilestoneSelect}
|
|
||||||
onAddPurchase={() => setActiveForm('purchase')}
|
|
||||||
onAddMilestone={() => setActiveForm('milestone')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: activeForm && showProgressBar && showStatsBox ? 'block' : 'none' }}>
|
|
||||||
<InlineForm
|
|
||||||
type={activeForm}
|
|
||||||
unit={tracker?.unit}
|
|
||||||
onClose={() => setActiveForm(null)}
|
|
||||||
onSuccess={(type) => {
|
|
||||||
if (type === 'purchase') handlePurchaseSuccess();
|
|
||||||
else if (type === 'milestone') handleMilestoneSuccess();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,3 @@
|
||||||
export interface Milestone {
|
|
||||||
id?: number;
|
|
||||||
target: number;
|
|
||||||
description: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrackerAsset {
|
export interface TrackerAsset {
|
||||||
id: number;
|
id: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AssetController;
|
use App\Http\Controllers\AssetController;
|
||||||
use App\Http\Controllers\Milestones\MilestoneController;
|
|
||||||
use App\Http\Controllers\Pricing\PricingController;
|
use App\Http\Controllers\Pricing\PricingController;
|
||||||
use App\Http\Controllers\TrackerController;
|
use App\Http\Controllers\TrackerController;
|
||||||
use App\Http\Controllers\Transactions\EntryController;
|
use App\Http\Controllers\Transactions\EntryController;
|
||||||
|
|
@ -45,10 +44,4 @@
|
||||||
Route::get('/date/{date}', [PricingController::class, 'forDate'])->name('for-date');
|
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__.'/auth.php';
|
require __DIR__.'/auth.php';
|
||||||
|
|
|
||||||
0
tests/Feature/.gitkeep
Normal file
0
tests/Feature/.gitkeep
Normal file
|
|
@ -1,73 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
use App\Models\Milestone;
|
|
||||||
use App\Models\Tracker;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class MilestoneTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
private function tracker(): Tracker
|
|
||||||
{
|
|
||||||
return Tracker::create([
|
|
||||||
'user_id' => User::default()->id,
|
|
||||||
'label' => 'Test',
|
|
||||||
'unit' => 'units',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_can_create_milestone(): void
|
|
||||||
{
|
|
||||||
$tracker = $this->tracker();
|
|
||||||
|
|
||||||
$milestone = Milestone::create([
|
|
||||||
'tracker_id' => $tracker->id,
|
|
||||||
'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
|
|
||||||
{
|
|
||||||
$tracker = $this->tracker();
|
|
||||||
Milestone::create(['tracker_id' => $tracker->id, 'target' => 1500, 'description' => 'First milestone']);
|
|
||||||
Milestone::create(['tracker_id' => $tracker->id, '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
|
|
||||||
{
|
|
||||||
$tracker = $this->tracker();
|
|
||||||
Milestone::create(['tracker_id' => $tracker->id, 'target' => 3000, 'description' => 'Third']);
|
|
||||||
Milestone::create(['tracker_id' => $tracker->id, 'target' => 1000, 'description' => 'First']);
|
|
||||||
Milestone::create(['tracker_id' => $tracker->id, '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