Compare commits
12 commits
fe3711e57c
...
6e76ce9c68
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e76ce9c68 | |||
| 5c1f3bb183 | |||
| 22e3394cb1 | |||
| b66e018b3a | |||
| 2f7a248e9f | |||
| 04fbda48fd | |||
| b1d0ab793c | |||
| 27f0ac8568 | |||
| 7a17d4d90c | |||
| 1d7c516eb2 | |||
| 4abceaff7e | |||
| 464b4083cf |
60 changed files with 12088 additions and 4181 deletions
10
.env.example
10
.env.example
|
|
@ -1,7 +1,7 @@
|
|||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
|
|
@ -18,18 +18,18 @@ BCRYPT_ROUNDS=12
|
|||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
LOG_LEVEL=error
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=incr
|
||||
DB_USERNAME=incr_user
|
||||
DB_PASSWORD=incr_password
|
||||
DB_PASSWORD=change_me_in_production
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_ENCRYPT=true
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,7 +12,6 @@
|
|||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/composer.lock
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
npm-debug.log
|
||||
|
|
|
|||
|
|
@ -1,62 +1,18 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AssetController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$assets = Asset::orderBy('symbol')->get();
|
||||
|
||||
return response()->json($assets);
|
||||
}
|
||||
|
||||
public function current(): JsonResponse
|
||||
{
|
||||
// Get the first/default user (since no auth)
|
||||
$user = User::first();
|
||||
$asset = $user ? $user->asset : null;
|
||||
|
||||
return response()->json([
|
||||
'asset' => $asset,
|
||||
'price_tracking_enabled' => $user?->price_tracking_enabled ?? false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function setCurrent(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'symbol' => 'required|string|max:10',
|
||||
'full_name' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$asset = Asset::findOrCreateBySymbol(
|
||||
$validated['symbol'],
|
||||
$validated['full_name'] ?? null
|
||||
);
|
||||
|
||||
// Get or create the first/default user (since no auth)
|
||||
$user = User::first();
|
||||
|
||||
if (! $user) {
|
||||
// Create a default user if none exists
|
||||
$user = User::create([
|
||||
'name' => 'Default User',
|
||||
'email' => 'user@example.com',
|
||||
'password' => 'password', // This will be hashed automatically
|
||||
'asset_id' => $asset->id,
|
||||
]);
|
||||
} else {
|
||||
$user->update(['asset_id' => $asset->id]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Asset set successfully!');
|
||||
return response()->json(Asset::orderBy('symbol')->get());
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
|
|
@ -81,11 +37,10 @@ public function store(Request $request): JsonResponse
|
|||
public function show(Asset $asset): JsonResponse
|
||||
{
|
||||
$asset->load('assetPrices');
|
||||
$currentPrice = $asset->currentPrice();
|
||||
|
||||
return response()->json([
|
||||
'asset' => $asset,
|
||||
'current_price' => $currentPrice,
|
||||
'current_price' => $asset->currentPrice(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the login page.
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Inertia::render('auth/login', [
|
||||
'canResetPassword' => Route::has('password.request'),
|
||||
'status' => $request->session()->get('status'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ConfirmablePasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the confirm password page.
|
||||
*/
|
||||
public function show(): Response
|
||||
{
|
||||
return Inertia::render('auth/confirm-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's password.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => $request->user()->email,
|
||||
'password' => $request->password,
|
||||
])) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('auth.password_confirmed_at', time());
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a new email verification notification.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with('status', 'verification-link-sent');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the email verification prompt page.
|
||||
*/
|
||||
public function __invoke(Request $request): Response|RedirectResponse
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the password reset page.
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Inertia::render('auth/reset-password', [
|
||||
'email' => $request->email,
|
||||
'token' => $request->route('token'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => 'required',
|
||||
'email' => 'required|email',
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
if ($status == Password::PasswordReset) {
|
||||
return to_route('login')->with('status', __($status));
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__($status)],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the password reset link request page.
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Inertia::render('auth/forgot-password', [
|
||||
'status' => $request->session()->get('status'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
]);
|
||||
|
||||
Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return back()->with('status', __('A reset link will be sent if the account exists.'));
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,10 @@ class RegisteredUserController extends Controller
|
|||
*/
|
||||
public function create(): Response
|
||||
{
|
||||
if (User::exists()) {
|
||||
abort(403, 'Registration is disabled.');
|
||||
}
|
||||
|
||||
return Inertia::render('auth/register');
|
||||
}
|
||||
|
||||
|
|
@ -31,16 +35,20 @@ public function create(): Response
|
|||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
if (User::exists()) {
|
||||
abort(403, 'Registration is disabled.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
$user = User::forceCreate([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
/** @var MustVerifyEmail $user */
|
||||
$user = $request->user();
|
||||
|
||||
event(new Verified($user));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Milestones;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Milestone;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -12,23 +14,30 @@ class MilestoneController extends Controller
|
|||
{
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
$validated = $request->validate([
|
||||
'target' => 'required|integer|min:1',
|
||||
'description' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
Milestone::create([
|
||||
'target' => $request->target,
|
||||
'description' => $request->description,
|
||||
]);
|
||||
$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
|
||||
{
|
||||
$milestones = Milestone::orderBy('target')->get();
|
||||
$tracker = User::default()->tracker;
|
||||
|
||||
return response()->json($milestones);
|
||||
if (! $tracker) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
return response()->json($tracker->milestones()->orderBy('target')->get());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Pricing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Pricing\AssetPrice;
|
||||
use App\Models\Tracker;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PricingController extends Controller
|
||||
{
|
||||
private ?Tracker $tracker;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->tracker = User::default()->tracker;
|
||||
}
|
||||
|
||||
public function current(): JsonResponse
|
||||
{
|
||||
// Get the first/default user (since no auth)
|
||||
$user = User::first();
|
||||
$assetId = $user ? $user->asset_id : null;
|
||||
|
||||
$price = AssetPrice::current($assetId);
|
||||
|
||||
return response()->json([
|
||||
'current_price' => $price,
|
||||
'current_price' => AssetPrice::current($this->tracker?->asset_id),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -30,17 +34,14 @@ public function update(Request $request)
|
|||
'price' => 'required|numeric|min:0.0001',
|
||||
]);
|
||||
|
||||
// Get the first/default user (since no auth)
|
||||
$user = User::first();
|
||||
|
||||
if (! $user || ! $user->asset_id) {
|
||||
if (! $this->tracker?->asset_id) {
|
||||
return back()->withErrors(['asset' => 'Please set an asset first.']);
|
||||
}
|
||||
|
||||
AssetPrice::updatePrice($user->asset_id, $validated['date'], $validated['price']);
|
||||
AssetPrice::updatePrice($this->tracker->asset_id, $validated['date'], $validated['price']);
|
||||
|
||||
if (! $user->price_tracking_enabled) {
|
||||
$user->update(['price_tracking_enabled' => true]);
|
||||
if (! $this->tracker->price_tracking_enabled) {
|
||||
$this->tracker->update(['price_tracking_enabled' => true]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Asset price updated successfully!');
|
||||
|
|
@ -48,27 +49,18 @@ public function update(Request $request)
|
|||
|
||||
public function history(Request $request): JsonResponse
|
||||
{
|
||||
// Get the first/default user (since no auth)
|
||||
$user = User::first();
|
||||
$assetId = $user ? $user->asset_id : null;
|
||||
$limit = min(max(1, $request->integer('limit', 30)), 365);
|
||||
|
||||
$limit = $request->get('limit', 30);
|
||||
$history = AssetPrice::history($assetId, $limit);
|
||||
|
||||
return response()->json($history);
|
||||
return response()->json(AssetPrice::history($this->tracker?->asset_id, $limit));
|
||||
}
|
||||
|
||||
public function forDate(Request $request, string $date): JsonResponse
|
||||
{
|
||||
// Get the first/default user (since no auth)
|
||||
$user = User::first();
|
||||
$assetId = $user ? $user->asset_id : null;
|
||||
|
||||
$price = AssetPrice::forDate($date, $assetId);
|
||||
validator(['date' => $date], ['date' => 'required|date_format:Y-m-d'])->validate();
|
||||
|
||||
return response()->json([
|
||||
'date' => $date,
|
||||
'price' => $price,
|
||||
'price' => AssetPrice::forDate($date, $this->tracker?->asset_id),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the user's password settings page.
|
||||
*/
|
||||
public function edit(): Response
|
||||
{
|
||||
return Inertia::render('settings/password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\ProfileUpdateRequest;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the user's profile settings page.
|
||||
*/
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
return Inertia::render('settings/profile', [
|
||||
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
|
||||
'status' => $request->session()->get('status'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile settings.
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
|
||||
return to_route('profile.edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
100
app/Http/Controllers/TrackerController.php
Normal file
100
app/Http/Controllers/TrackerController.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TrackerController extends Controller
|
||||
{
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
$tracker = User::default()->tracker;
|
||||
|
||||
if (! $tracker) {
|
||||
return response()->json(null);
|
||||
}
|
||||
|
||||
return response()->json($tracker->load('asset'));
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'label' => 'required|string|max:255',
|
||||
'unit' => 'required|string|max:50',
|
||||
'price_tracking_enabled' => 'boolean',
|
||||
'symbol' => 'nullable|string|max:10',
|
||||
'full_name' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$user = User::default();
|
||||
|
||||
if ($user->tracker) {
|
||||
return response()->json(['error' => 'Tracker already exists.'], 409);
|
||||
}
|
||||
|
||||
$assetId = null;
|
||||
if (! empty($validated['symbol'])) {
|
||||
$asset = Asset::findOrCreateBySymbol($validated['symbol'], $validated['full_name'] ?? null);
|
||||
$assetId = $asset->id;
|
||||
}
|
||||
|
||||
$tracker = $user->tracker()->create([
|
||||
'label' => $validated['label'],
|
||||
'unit' => $validated['unit'],
|
||||
'price_tracking_enabled' => $validated['price_tracking_enabled'] ?? false,
|
||||
'asset_id' => $assetId,
|
||||
]);
|
||||
|
||||
return response()->json($tracker->load('asset'), 201);
|
||||
}
|
||||
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'label' => 'sometimes|string|max:255',
|
||||
'unit' => 'sometimes|string|max:50',
|
||||
'price_tracking_enabled' => 'sometimes|boolean',
|
||||
'symbol' => 'nullable|string|max:10',
|
||||
'full_name' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$tracker = User::default()->tracker;
|
||||
|
||||
if (! $tracker) {
|
||||
return response()->json(['error' => 'No tracker found.'], 404);
|
||||
}
|
||||
|
||||
if (array_key_exists('symbol', $validated)) {
|
||||
if ($validated['symbol']) {
|
||||
$asset = Asset::findOrCreateBySymbol($validated['symbol'], $validated['full_name'] ?? null);
|
||||
$tracker->asset_id = $asset->id;
|
||||
} else {
|
||||
$tracker->asset_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
$update = [];
|
||||
if (isset($validated['label'])) {
|
||||
$update['label'] = $validated['label'];
|
||||
}
|
||||
if (isset($validated['unit'])) {
|
||||
$update['unit'] = $validated['unit'];
|
||||
}
|
||||
if (array_key_exists('price_tracking_enabled', $validated)) {
|
||||
$update['price_tracking_enabled'] = $validated['price_tracking_enabled'];
|
||||
}
|
||||
if (array_key_exists('symbol', $validated)) {
|
||||
$update['asset_id'] = $tracker->asset_id;
|
||||
}
|
||||
|
||||
$tracker->update($update);
|
||||
|
||||
return response()->json($tracker->load('asset'));
|
||||
}
|
||||
}
|
||||
91
app/Http/Controllers/Transactions/EntryController.php
Normal file
91
app/Http/Controllers/Transactions/EntryController.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Transactions;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Transactions\Entry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EntryController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$tracker = User::default()->tracker;
|
||||
|
||||
if (! $tracker) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
return response()->json($tracker->entries()->orderBy('date', 'desc')->get());
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'date' => 'required|date|before_or_equal:today',
|
||||
'quantity' => 'required|numeric|min:0.000001',
|
||||
'unit_price' => 'nullable|numeric|min:0.01',
|
||||
'total_cost' => 'nullable|numeric|min:0.01',
|
||||
]);
|
||||
|
||||
$tracker = User::default()->tracker;
|
||||
|
||||
if (! $tracker) {
|
||||
return back()->withErrors(['tracker' => 'No tracker found. Please complete onboarding first.']);
|
||||
}
|
||||
|
||||
// If unit_price and total_cost provided, verify the calculation
|
||||
if (isset($validated['unit_price'], $validated['total_cost'])) {
|
||||
$calculatedTotal = $validated['quantity'] * $validated['unit_price'];
|
||||
if (abs($calculatedTotal - $validated['total_cost']) > 0.01) {
|
||||
return back()->withErrors([
|
||||
'total_cost' => 'Total cost does not match quantity × unit price.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$tracker->entries()->create($validated);
|
||||
|
||||
return back()->with('success', 'Entry added successfully!');
|
||||
}
|
||||
|
||||
public function summary(): JsonResponse
|
||||
{
|
||||
$tracker = User::default()->tracker;
|
||||
|
||||
if (! $tracker) {
|
||||
return response()->json([
|
||||
'total_quantity' => 0,
|
||||
'total_cost' => 0,
|
||||
'average_cost_per_unit' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'total_quantity' => Entry::totalQuantity($tracker->id),
|
||||
'total_cost' => Entry::totalCost($tracker->id),
|
||||
'average_cost_per_unit' => Entry::averageCostPerUnit($tracker->id),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Entry $entry): JsonResponse
|
||||
{
|
||||
$tracker = User::default()->tracker;
|
||||
|
||||
if (! $tracker || $entry->tracker_id !== $tracker->id) {
|
||||
return response()->json(['error' => 'Entry not found.'], 404);
|
||||
}
|
||||
|
||||
$entry->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Entry deleted successfully!',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Transactions;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Transactions\Purchase;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PurchaseController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$purchases = Purchase::orderBy('date', 'desc')->get();
|
||||
|
||||
return response()->json($purchases);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$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 back()->with('success', '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!',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ public function share(Request $request): array
|
|||
'name' => config('app.name'),
|
||||
'quote' => ['message' => trim($message), 'author' => trim($author)],
|
||||
'auth' => [
|
||||
'user' => $request->user(),
|
||||
'user' => $request->user()?->only(['id', 'name', 'email']),
|
||||
],
|
||||
'ziggy' => fn (): array => [
|
||||
...(new Ziggy)->toArray(),
|
||||
|
|
|
|||
|
|
@ -35,11 +35,6 @@ public function assetPrices(): HasMany
|
|||
return $this->hasMany(Pricing\AssetPrice::class);
|
||||
}
|
||||
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class);
|
||||
}
|
||||
|
||||
public function currentPrice(): ?float
|
||||
{
|
||||
$latestPrice = $this->assetPrices()->latest('date')->first();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @method static create(array $array)
|
||||
|
|
@ -11,6 +12,7 @@
|
|||
class Milestone extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'tracker_id',
|
||||
'target',
|
||||
'description',
|
||||
];
|
||||
|
|
@ -18,4 +20,9 @@ class Milestone extends Model
|
|||
protected $casts = [
|
||||
'target' => 'integer',
|
||||
];
|
||||
|
||||
public function tracker(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tracker::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
app/Models/Tracker.php
Normal file
48
app/Models/Tracker.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Transactions\Entry;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Tracker extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'asset_id',
|
||||
'label',
|
||||
'unit',
|
||||
'price_tracking_enabled',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'price_tracking_enabled' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function asset(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Asset::class);
|
||||
}
|
||||
|
||||
public function entries(): HasMany
|
||||
{
|
||||
return $this->hasMany(Entry::class);
|
||||
}
|
||||
|
||||
public function milestones(): HasMany
|
||||
{
|
||||
return $this->hasMany(Milestone::class);
|
||||
}
|
||||
}
|
||||
53
app/Models/Transactions/Entry.php
Normal file
53
app/Models/Transactions/Entry.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Transactions;
|
||||
|
||||
use App\Models\Tracker;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Entry extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'tracker_id',
|
||||
'date',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'total_cost',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'date' => 'date',
|
||||
'quantity' => 'decimal:6',
|
||||
'unit_price' => 'decimal:4',
|
||||
'total_cost' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function tracker(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tracker::class);
|
||||
}
|
||||
|
||||
public static function totalQuantity(int $trackerId): float
|
||||
{
|
||||
return (float) static::where('tracker_id', $trackerId)->sum('quantity');
|
||||
}
|
||||
|
||||
public static function totalCost(int $trackerId): float
|
||||
{
|
||||
return (float) static::where('tracker_id', $trackerId)->sum('total_cost');
|
||||
}
|
||||
|
||||
public static function averageCostPerUnit(int $trackerId): float
|
||||
{
|
||||
$totalQuantity = static::totalQuantity($trackerId);
|
||||
$totalCost = static::totalCost($trackerId);
|
||||
|
||||
return $totalQuantity > 0 ? $totalCost / $totalQuantity : 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,40 +2,23 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Models\Transactions\Purchase;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @property int|null $asset_id
|
||||
*/
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'asset_id',
|
||||
'price_tracking_enabled',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
|
|
@ -46,27 +29,36 @@ protected function casts(): array
|
|||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'price_tracking_enabled' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function asset(): BelongsTo
|
||||
public function tracker(): HasOne
|
||||
{
|
||||
return $this->belongsTo(Asset::class);
|
||||
return $this->hasOne(Tracker::class);
|
||||
}
|
||||
|
||||
public static function default(): self
|
||||
{
|
||||
return self::firstWhere('email', 'user@incr.local')
|
||||
?? self::forceCreate([
|
||||
'email' => 'user@incr.local',
|
||||
'name' => 'Default User',
|
||||
'password' => bcrypt(Str::random(32)),
|
||||
]);
|
||||
}
|
||||
|
||||
public function hasCompletedOnboarding(): bool
|
||||
{
|
||||
return $this->hasPurchases() && $this->hasMilestones();
|
||||
return $this->hasEntries() && $this->hasMilestones();
|
||||
}
|
||||
|
||||
public function hasPurchases(): bool
|
||||
public function hasEntries(): bool
|
||||
{
|
||||
return Purchase::totalShares() > 0;
|
||||
return (bool) $this->tracker?->entries()->exists();
|
||||
}
|
||||
|
||||
public function hasMilestones(): bool
|
||||
{
|
||||
return Milestone::count() > 0;
|
||||
return (bool) $this->tracker?->milestones()->exists();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@
|
|||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"inertiajs/inertia-laravel": "^3.0",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/tinker": "^3.0",
|
||||
"tightenco/ziggy": "^2.4"
|
||||
},
|
||||
"require-dev": {
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
"laravel/sail": "^1.43",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^11.5.3"
|
||||
"phpunit/phpunit": "^12.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
|
|
|||
8516
composer.lock
generated
Normal file
8516
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,50 @@
|
|||
<?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
|
||||
{
|
||||
Schema::create('trackers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('asset_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('label');
|
||||
$table->string('unit');
|
||||
$table->boolean('price_tracking_enabled')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// Migrate existing users: create one tracker per user from their current asset_id + price_tracking_enabled
|
||||
DB::table('users')->orderBy('id')->each(function (object $user) {
|
||||
DB::table('trackers')->insert([
|
||||
'user_id' => $user->id,
|
||||
'asset_id' => $user->asset_id,
|
||||
'label' => 'Portfolio',
|
||||
'unit' => 'shares',
|
||||
'price_tracking_enabled' => $user->price_tracking_enabled ?? false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Restore asset_id and price_tracking_enabled back onto users before dropping trackers
|
||||
DB::table('trackers')->orderBy('id')->each(function (object $tracker) {
|
||||
DB::table('users')
|
||||
->where('id', $tracker->user_id)
|
||||
->update([
|
||||
'asset_id' => $tracker->asset_id,
|
||||
'price_tracking_enabled' => $tracker->price_tracking_enabled,
|
||||
]);
|
||||
});
|
||||
|
||||
Schema::dropIfExists('trackers');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?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
|
||||
{
|
||||
Schema::table('milestones', function (Blueprint $table) {
|
||||
$table->foreignId('tracker_id')->nullable()->after('id')->constrained()->cascadeOnDelete();
|
||||
});
|
||||
|
||||
// Backfill tracker_id on milestones
|
||||
$trackerId = DB::table('trackers')->value('id');
|
||||
if ($trackerId) {
|
||||
DB::table('milestones')->update(['tracker_id' => $trackerId]);
|
||||
}
|
||||
|
||||
Schema::table('milestones', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('tracker_id')->nullable(false)->change();
|
||||
});
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropForeign(['asset_id']);
|
||||
$table->dropColumn(['asset_id', 'price_tracking_enabled']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->foreignId('asset_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->boolean('price_tracking_enabled')->default(false);
|
||||
});
|
||||
|
||||
Schema::table('milestones', function (Blueprint $table) {
|
||||
$table->dropForeign(['tracker_id']);
|
||||
$table->dropColumn('tracker_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -4,6 +4,12 @@ server {
|
|||
root /var/www/html/public;
|
||||
index index.php index.html;
|
||||
|
||||
server_tokens off;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ fi
|
|||
|
||||
# Wait for database to be ready
|
||||
echo "Waiting for database..."
|
||||
until php artisan tinker --execute="DB::connection()->getPdo();" 2>/dev/null; do
|
||||
until mysql -h"${DB_HOST:-db}" -u"${DB_USERNAME:-incr_user}" -p"${DB_PASSWORD}" -e "SELECT 1" >/dev/null 2>&1; do
|
||||
echo "Database not ready, waiting..."
|
||||
sleep 2
|
||||
done
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
user=www-data
|
||||
|
||||
[program:nginx]
|
||||
command=nginx -g "daemon off;"
|
||||
|
|
|
|||
4867
package-lock.json
generated
4867
package-lock.json
generated
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
|
@ -13,7 +13,7 @@
|
|||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@types/node": "^22.13.5",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@inertiajs/react": "^2.0.0",
|
||||
"@inertiajs/react": "^3.0.3",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
|
|
@ -41,20 +41,20 @@
|
|||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@types/react": "^19.0.3",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"globals": "^15.14.0",
|
||||
"laravel-vite-plugin": "^1.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
"laravel-vite-plugin": "^3.1.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0"
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ interface AssetSetupFormProps {
|
|||
}
|
||||
|
||||
export default function AssetSetupForm({ onSuccess, onCancel }: AssetSetupFormProps) {
|
||||
const { data, setData, post, processing, errors } = useForm<AssetFormData>({
|
||||
const { data, setData, patch, processing, errors } = useForm<AssetFormData>({
|
||||
symbol: '',
|
||||
full_name: '',
|
||||
});
|
||||
|
|
@ -28,13 +28,13 @@ export default function AssetSetupForm({ onSuccess, onCancel }: AssetSetupFormPr
|
|||
useEffect(() => {
|
||||
const fetchCurrentAsset = async () => {
|
||||
try {
|
||||
const response = await fetch('/assets/current');
|
||||
const response = await fetch('/tracker');
|
||||
if (response.ok) {
|
||||
const assetData = await response.json();
|
||||
if (assetData.asset) {
|
||||
const tracker = await response.json();
|
||||
if (tracker?.asset) {
|
||||
setData({
|
||||
symbol: assetData.asset.symbol || '',
|
||||
full_name: assetData.asset.full_name || '',
|
||||
symbol: tracker.asset.symbol || '',
|
||||
full_name: tracker.asset.full_name || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ export default function AssetSetupForm({ onSuccess, onCancel }: AssetSetupFormPr
|
|||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
post(route('assets.set-current'), {
|
||||
patch(route('tracker.update'), {
|
||||
onSuccess: () => {
|
||||
if (onSuccess) onSuccess();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import AddEntryForm from '@/components/Transactions/AddEntryForm';
|
||||
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
||||
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
|
||||
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
|
||||
import { cn } from '@/lib/utils';
|
||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
||||
|
||||
interface InlineFormProps {
|
||||
type: 'purchase' | 'milestone' | 'price' | null;
|
||||
unit?: string;
|
||||
priceTrackingEnabled?: boolean;
|
||||
onClose: () => void;
|
||||
onPurchaseSuccess?: () => void;
|
||||
onMilestoneSuccess?: () => void;
|
||||
|
|
@ -15,31 +16,30 @@ interface InlineFormProps {
|
|||
|
||||
export default function InlineForm({
|
||||
type,
|
||||
unit = 'units',
|
||||
priceTrackingEnabled = false,
|
||||
onClose,
|
||||
onPurchaseSuccess,
|
||||
onMilestoneSuccess,
|
||||
onPriceSuccess,
|
||||
className
|
||||
className,
|
||||
}: InlineFormProps) {
|
||||
if (!type) return null;
|
||||
|
||||
const title = type === 'purchase' ? 'ADD PURCHASE' : type === 'milestone' ? 'ADD MILESTONE' : 'UPDATE PRICE';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-black p-8",
|
||||
"transition-all duration-300",
|
||||
className
|
||||
'bg-black p-8',
|
||||
'transition-all duration-300',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
|
||||
|
||||
{/* Form Content */}
|
||||
<div className="flex justify-center">
|
||||
{type === 'purchase' ? (
|
||||
<AddPurchaseForm
|
||||
<AddEntryForm
|
||||
unit={unit}
|
||||
priceTrackingEnabled={priceTrackingEnabled}
|
||||
onSuccess={() => {
|
||||
if (onPurchaseSuccess) onPurchaseSuccess();
|
||||
onClose();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
|||
|
||||
interface LedDisplayProps {
|
||||
value: number;
|
||||
unit?: string;
|
||||
className?: string;
|
||||
animate?: boolean;
|
||||
onClick?: () => void;
|
||||
|
|
@ -10,6 +11,7 @@ interface LedDisplayProps {
|
|||
|
||||
export default function LedDisplay({
|
||||
value,
|
||||
unit,
|
||||
className,
|
||||
onClick
|
||||
}: LedDisplayProps) {
|
||||
|
|
@ -55,6 +57,11 @@ export default function LedDisplay({
|
|||
{formattedValue}
|
||||
</div>
|
||||
</div>
|
||||
{unit && (
|
||||
<div className="text-red-500/50 font-mono text-sm uppercase tracking-widest mt-2">
|
||||
{unit}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,28 +7,26 @@ interface Milestone {
|
|||
}
|
||||
|
||||
interface ProgressBarProps {
|
||||
currentShares: number;
|
||||
currentQuantity: number;
|
||||
milestones: Milestone[];
|
||||
selectedMilestoneIndex?: number;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function ProgressBar({
|
||||
currentShares,
|
||||
export default function ProgressBar({
|
||||
currentQuantity,
|
||||
milestones,
|
||||
selectedMilestoneIndex = 0,
|
||||
className,
|
||||
onClick
|
||||
}: ProgressBarProps) {
|
||||
// Get the selected milestone for progress calculation
|
||||
const selectedMilestone = milestones.length > 0 && selectedMilestoneIndex < milestones.length
|
||||
? milestones[selectedMilestoneIndex]
|
||||
const selectedMilestone = milestones.length > 0 && selectedMilestoneIndex < milestones.length
|
||||
? milestones[selectedMilestoneIndex]
|
||||
: null;
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage = selectedMilestone
|
||||
? Math.min((currentShares / selectedMilestone.target) * 100, 100)
|
||||
|
||||
const progressPercentage = selectedMilestone
|
||||
? Math.min((currentQuantity / selectedMilestone.target) * 100, 100)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ interface StatsBoxProps {
|
|||
profitLoss?: number;
|
||||
profitLossPercentage?: number;
|
||||
};
|
||||
unit?: string;
|
||||
milestones?: Milestone[];
|
||||
selectedMilestoneIndex?: number;
|
||||
onMilestoneSelect?: (index: number) => void;
|
||||
|
|
@ -32,6 +33,7 @@ interface StatsBoxProps {
|
|||
|
||||
export default function StatsBox({
|
||||
stats,
|
||||
unit = 'units',
|
||||
milestones = [],
|
||||
selectedMilestoneIndex = 0,
|
||||
onMilestoneSelect,
|
||||
|
|
@ -107,7 +109,7 @@ export default function StatsBox({
|
|||
}}
|
||||
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
|
||||
ADD ENTRY
|
||||
</button>
|
||||
)}
|
||||
{onAddMilestone && (
|
||||
|
|
@ -157,7 +159,7 @@ export default function StatsBox({
|
|||
<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">{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>
|
||||
|
|
|
|||
161
resources/js/components/Onboarding/CreateTrackerStep.tsx
Normal file
161
resources/js/components/Onboarding/CreateTrackerStep.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
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;
|
||||
}
|
||||
|
||||
export default function CreateTrackerStep({ onSuccess }: CreateTrackerStepProps) {
|
||||
const [priceTracking, setPriceTracking] = useState(false);
|
||||
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
|
||||
post(route('tracker.store'), {
|
||||
onSuccess: () => {
|
||||
onSuccess(priceTracking);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="space-y-4">
|
||||
<ComponentTitle>SET UP YOUR TRACKER</ComponentTitle>
|
||||
<p className="text-sm text-red-400/60 font-mono">
|
||||
[SYSTEM] What are you tracking?
|
||||
</p>
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="label" className="text-red-400 font-mono text-xs uppercase tracking-wider">
|
||||
> Tracker Name
|
||||
</Label>
|
||||
<Input
|
||||
id="label"
|
||||
type="text"
|
||||
placeholder="My Portfolio"
|
||||
value={data.label}
|
||||
onChange={(e) => setData('label', 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">
|
||||
[REQUIRED] e.g. "My Portfolio", "Books Read", "KM Run"
|
||||
</p>
|
||||
<InputError message={errors.label} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="unit" className="text-red-400 font-mono text-xs uppercase tracking-wider">
|
||||
> Unit
|
||||
</Label>
|
||||
<Input
|
||||
id="unit"
|
||||
type="text"
|
||||
placeholder="shares"
|
||||
value={data.unit}
|
||||
onChange={(e) => setData('unit', 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">
|
||||
[REQUIRED] e.g. "shares", "books", "km"
|
||||
</p>
|
||||
<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)}
|
||||
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" />}
|
||||
[INITIALIZE]
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,46 +1,9 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import AssetSetupForm from '@/components/Assets/AssetSetupForm';
|
||||
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
|
||||
import AddEntryForm from '@/components/Transactions/AddEntryForm';
|
||||
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
||||
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
|
||||
|
||||
type TrackerType = 'simple' | 'asset';
|
||||
|
||||
function TrackerTypeSelector({ onSelect }: { onSelect: (type: TrackerType) => void }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-red-400 font-mono text-sm uppercase tracking-wider">
|
||||
[SELECT] What do you want to track?
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => onSelect('simple')}
|
||||
className="text-left border-2 border-red-500/50 bg-black p-6 hover:bg-red-950/30 hover:border-red-400 hover:shadow-[0_0_20px_rgba(239,68,68,0.3)] transition-all"
|
||||
>
|
||||
<span className="block text-red-400 font-mono text-lg font-bold uppercase tracking-wider mb-3">
|
||||
[01] Simple counter
|
||||
</span>
|
||||
<p className="text-red-400/60 font-mono text-xs">
|
||||
Track anything you accumulate — no price tracking, no asset setup.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onSelect('asset')}
|
||||
className="text-left border-2 border-red-500/50 bg-black p-6 hover:bg-red-950/30 hover:border-red-400 hover:shadow-[0_0_20px_rgba(239,68,68,0.3)] transition-all"
|
||||
>
|
||||
<span className="block text-red-400 font-mono text-lg font-bold uppercase tracking-wider mb-3">
|
||||
[02] Asset tracker
|
||||
</span>
|
||||
<p className="text-red-400/60 font-mono text-xs">
|
||||
Track holdings with price tracking and P&L.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import CreateTrackerStep from '@/components/Onboarding/CreateTrackerStep';
|
||||
|
||||
function PriceTrackingStep({ onEnable, onSkip }: { onEnable: () => void; onSkip?: () => void }) {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
|
@ -70,7 +33,7 @@ function PriceTrackingStep({ onEnable, onSkip }: { onEnable: () => void; onSkip?
|
|||
|
||||
{!enabled && (
|
||||
<button
|
||||
onClick={onSkip}
|
||||
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
|
||||
|
|
@ -89,14 +52,13 @@ interface OnboardingStep {
|
|||
}
|
||||
|
||||
const ASSET_STEPS: OnboardingStep[] = [
|
||||
{ id: 'asset', title: 'SET ASSET', description: 'Choose the asset you want to track', completed: false, required: true },
|
||||
{ id: 'purchases', title: 'ADD PURCHASES', description: 'Enter your current holdings', completed: false, required: true },
|
||||
{ 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[] = [
|
||||
{ id: 'purchases', title: 'STARTING AMOUNT', description: 'Enter your starting amount', completed: false, required: true },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
|
|
@ -105,29 +67,27 @@ interface OnboardingFlowProps {
|
|||
}
|
||||
|
||||
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
||||
const [trackerType, setTrackerType] = useState<TrackerType | null>(null);
|
||||
const [trackerCreated, setTrackerCreated] = useState(false);
|
||||
const [priceTracking, setPriceTracking] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [steps, setSteps] = useState<OnboardingStep[]>([]);
|
||||
|
||||
const checkOnboardingStatus = useCallback(async (currentSteps: OnboardingStep[]) => {
|
||||
try {
|
||||
const [purchaseData, milestonesData, assetData, priceData] = await Promise.all([
|
||||
fetch('/purchases/summary').then(r => r.json()),
|
||||
const [entriesData, milestonesData, priceData] = await Promise.all([
|
||||
fetch('/entries/summary').then(r => r.json()),
|
||||
fetch('/milestones').then(r => r.json()),
|
||||
fetch('/assets/current').then(r => r.json()),
|
||||
fetch('/pricing/current').then(r => r.json()),
|
||||
]);
|
||||
|
||||
const hasPurchases = purchaseData.total_shares > 0;
|
||||
const hasEntries = entriesData.total_quantity > 0;
|
||||
const hasMilestones = milestonesData.length > 0;
|
||||
const hasAsset = !!assetData.asset;
|
||||
const hasPrice = !!priceData.current_price;
|
||||
|
||||
const freshSteps = currentSteps.map(step => ({
|
||||
...step,
|
||||
completed:
|
||||
(step.id === 'asset' && hasAsset) ||
|
||||
(step.id === 'purchases' && hasPurchases) ||
|
||||
(step.id === 'entries' && hasEntries) ||
|
||||
(step.id === 'milestones' && hasMilestones) ||
|
||||
(step.id === 'price' && hasPrice),
|
||||
}));
|
||||
|
|
@ -147,15 +107,18 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
|||
}, [onComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackerType === null) {
|
||||
return;
|
||||
}
|
||||
if (!trackerCreated) return;
|
||||
|
||||
const initialSteps = trackerType === 'simple' ? SIMPLE_STEPS : ASSET_STEPS;
|
||||
const initialSteps = priceTracking ? ASSET_STEPS : SIMPLE_STEPS;
|
||||
setSteps(initialSteps);
|
||||
setCurrentStep(0);
|
||||
checkOnboardingStatus(initialSteps);
|
||||
}, [trackerType, checkOnboardingStatus]);
|
||||
}, [trackerCreated, priceTracking, checkOnboardingStatus]);
|
||||
|
||||
const handleTrackerCreated = (withPriceTracking: boolean) => {
|
||||
setPriceTracking(withPriceTracking);
|
||||
setTrackerCreated(true);
|
||||
};
|
||||
|
||||
const handleStepComplete = async () => {
|
||||
const updatedSteps = steps.map((step, index) =>
|
||||
|
|
@ -171,15 +134,11 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
|||
|
||||
const renderStepContent = () => {
|
||||
const step = steps[currentStep];
|
||||
if (!step) {
|
||||
return null;
|
||||
}
|
||||
if (!step) return null;
|
||||
|
||||
switch (step.id) {
|
||||
case 'asset':
|
||||
return <AssetSetupForm onSuccess={handleStepComplete} />;
|
||||
case 'purchases':
|
||||
return <AddPurchaseForm onSuccess={handleStepComplete} />;
|
||||
case 'entries':
|
||||
return <AddEntryForm onSuccess={handleStepComplete} priceTrackingEnabled={priceTracking} />;
|
||||
case 'milestones':
|
||||
return <AddMilestoneForm onSuccess={handleStepComplete} />;
|
||||
case 'price':
|
||||
|
|
@ -198,13 +157,13 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
|||
[SYSTEM] ONBOARDING SEQUENCE
|
||||
</h1>
|
||||
<p className="text-red-400/60 font-mono text-sm">
|
||||
{trackerType === null ? 'Choose how you want to track' : 'Set up your tracker'}
|
||||
{!trackerCreated ? 'Set up your tracker' : 'Configure your tracker'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{trackerType === null ? (
|
||||
{!trackerCreated ? (
|
||||
<div className="border border-red-500/30 bg-black/50 p-6">
|
||||
<TrackerTypeSelector onSelect={setTrackerType} />
|
||||
<CreateTrackerStep onSuccess={handleTrackerCreated} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/DropdownMenu';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
export default function AppearanceToggleDropdown({ className = '', ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
|
||||
const getCurrentIcon = () => {
|
||||
switch (appearance) {
|
||||
case 'dark':
|
||||
return <Moon className="h-5 w-5" />;
|
||||
case 'light':
|
||||
return <Sun className="h-5 w-5" />;
|
||||
default:
|
||||
return <Monitor className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} {...props}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-md">
|
||||
{getCurrentIcon()}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => updateAppearance('light')}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Sun className="h-5 w-5" />
|
||||
Light
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => updateAppearance('dark')}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Moon className="h-5 w-5" />
|
||||
Dark
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => updateAppearance('system')}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
System
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { Appearance, useAppearance } from '@/hooks/use-appearance';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LucideIcon, Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
export default function AppearanceToggleTab({ className = '', ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
|
||||
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
|
||||
{ value: 'light', icon: Sun, label: 'Light' },
|
||||
{ value: 'dark', icon: Moon, label: 'Dark' },
|
||||
{ value: 'system', icon: Monitor, label: 'System' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn('inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800', className)} {...props}>
|
||||
{tabs.map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => updateAppearance(value)}
|
||||
className={cn(
|
||||
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
|
||||
appearance === value
|
||||
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100'
|
||||
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
|
||||
)}
|
||||
>
|
||||
<Icon className="-ml-1 h-4 w-4" />
|
||||
<span className="ml-1.5 text-sm">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
resources/js/components/Transactions/AddEntryForm.tsx
Normal file
181
resources/js/components/Transactions/AddEntryForm.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
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, useState } from 'react';
|
||||
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) {
|
||||
const { data, setData, post, processing, errors, reset } = useForm<EntryFormData>({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
quantity: '',
|
||||
unit_price: '',
|
||||
total_cost: '',
|
||||
});
|
||||
|
||||
const [currentHoldings, setCurrentHoldings] = useState<EntrySummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSummary = async () => {
|
||||
try {
|
||||
const response = await fetch('/entries/summary');
|
||||
if (response.ok) {
|
||||
const summary = await response.json();
|
||||
setCurrentHoldings(summary);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch entry summary:', error);
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
post(route('entries.store'), {
|
||||
onSuccess: () => {
|
||||
reset();
|
||||
setData('date', new Date().toISOString().split('T')[0]);
|
||||
if (onSuccess) onSuccess();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="space-y-4">
|
||||
<ComponentTitle>ADD ENTRY</ComponentTitle>
|
||||
{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">
|
||||
<div>
|
||||
<Label htmlFor="date" className="text-red-400 font-mono text-xs uppercase tracking-wider">> 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 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none transition-all glow-red"
|
||||
/>
|
||||
<InputError message={errors.date} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="quantity" className="text-red-400 font-mono text-xs uppercase tracking-wider">
|
||||
> Quantity ({unit})
|
||||
</Label>
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
placeholder="1.234567"
|
||||
value={data.quantity}
|
||||
onChange={(e) => setData('quantity', 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.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"
|
||||
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,68 +0,0 @@
|
|||
import Heading from '@/components/heading';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type NavItem } from '@/types';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
const sidebarNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Profile',
|
||||
href: '/settings/profile',
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
title: 'Password',
|
||||
href: '/settings/password',
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
href: '/settings/appearance',
|
||||
icon: null,
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsLayout({ children }: PropsWithChildren) {
|
||||
// When server-side rendering, we only render the layout on the client...
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6">
|
||||
<Heading title="Settings" description="Manage your profile and account settings" />
|
||||
|
||||
<div className="flex flex-col space-y-8 lg:flex-row lg:space-y-0 lg:space-x-12">
|
||||
<aside className="w-full max-w-xl lg:w-48">
|
||||
<nav className="flex flex-col space-y-1 space-x-0">
|
||||
{sidebarNavItems.map((item, index) => (
|
||||
<Button
|
||||
key={`${item.href}-${index}`}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
asChild
|
||||
className={cn('w-full justify-start', {
|
||||
'bg-muted': currentPath === item.href,
|
||||
})}
|
||||
>
|
||||
<Link href={item.href} prefetch>
|
||||
{item.title}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<Separator className="my-6 md:hidden" />
|
||||
|
||||
<div className="flex-1 md:max-w-2xl">
|
||||
<section className="max-w-xl space-y-12">{children}</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
// Components
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { FormEventHandler } from 'react';
|
||||
|
||||
import InputError from '@/components/InputError';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
export default function ConfirmPassword() {
|
||||
const { data, setData, post, processing, errors, reset } = useForm<Required<{ password: string }>>({
|
||||
password: '',
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
post(route('password.confirm'), {
|
||||
onFinish: () => reset('password'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Confirm your password"
|
||||
description="This is a secure area of the application. Please confirm your password before continuing."
|
||||
>
|
||||
<Head title="Confirm password" />
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
value={data.password}
|
||||
autoFocus
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
/>
|
||||
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Button className="w-full" disabled={processing}>
|
||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||
Confirm password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
// Components
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { FormEventHandler } from 'react';
|
||||
|
||||
import InputError from '@/components/InputError';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
export default function ForgotPassword({ status }: { status?: string }) {
|
||||
const { data, setData, post, processing, errors } = useForm<Required<{ email: string }>>({
|
||||
email: '',
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
post(route('password.email'));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Forgot password" description="Enter your email to receive a password reset link">
|
||||
<Head title="Forgot password" />
|
||||
|
||||
{status && <div className="mb-4 text-center text-sm font-medium text-green-600">{status}</div>}
|
||||
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={submit}>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="off"
|
||||
value={data.email}
|
||||
autoFocus
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
|
||||
<div className="my-6 flex items-center justify-start">
|
||||
<Button className="w-full" disabled={processing}>
|
||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||
Email password reset link
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="space-x-1 text-center text-sm text-muted-foreground">
|
||||
<span>Or, return to</span>
|
||||
<TextLink href={route('login')}>log in</TextLink>
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { FormEventHandler } from 'react';
|
||||
|
||||
import InputError from '@/components/InputError';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
type LoginForm = {
|
||||
email: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
};
|
||||
|
||||
interface LoginProps {
|
||||
status?: string;
|
||||
canResetPassword: boolean;
|
||||
}
|
||||
|
||||
export default function Login({ status, canResetPassword }: LoginProps) {
|
||||
const { data, setData, post, processing, errors, reset } = useForm<Required<LoginForm>>({
|
||||
email: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
post(route('login'), {
|
||||
onFinish: () => reset('password'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Log in to your account" description="Enter your email and password below to log in">
|
||||
<Head title="Log in" />
|
||||
|
||||
<form className="flex flex-col gap-6" onSubmit={submit}>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="email"
|
||||
value={data.email}
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
{canResetPassword && (
|
||||
<TextLink href={route('password.request')} className="ml-auto text-sm" tabIndex={5}>
|
||||
Forgot password?
|
||||
</TextLink>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
tabIndex={2}
|
||||
autoComplete="current-password"
|
||||
value={data.password}
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
name="remember"
|
||||
checked={data.remember}
|
||||
onClick={() => setData('remember', !data.remember)}
|
||||
tabIndex={3}
|
||||
/>
|
||||
<Label htmlFor="remember">Remember me</Label>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="mt-4 w-full" tabIndex={4} disabled={processing}>
|
||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||
Log in
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<TextLink href={route('register')} tabIndex={5}>
|
||||
Sign up
|
||||
</TextLink>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{status && <div className="mb-4 text-center text-sm font-medium text-green-600">{status}</div>}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { FormEventHandler } from 'react';
|
||||
|
||||
import InputError from '@/components/InputError';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
type RegisterForm = {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
};
|
||||
|
||||
export default function Register() {
|
||||
const { data, setData, post, processing, errors, reset } = useForm<Required<RegisterForm>>({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
post(route('register'), {
|
||||
onFinish: () => reset('password', 'password_confirmation'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Create an account" description="Enter your details below to create your account">
|
||||
<Head title="Register" />
|
||||
<form className="flex flex-col gap-6" onSubmit={submit}>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="name"
|
||||
value={data.name}
|
||||
onChange={(e) => setData('name', e.target.value)}
|
||||
disabled={processing}
|
||||
placeholder="Full name"
|
||||
/>
|
||||
<InputError message={errors.name} className="mt-2" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
tabIndex={2}
|
||||
autoComplete="email"
|
||||
value={data.email}
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
disabled={processing}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
tabIndex={3}
|
||||
autoComplete="new-password"
|
||||
value={data.password}
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
disabled={processing}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">Confirm password</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
type="password"
|
||||
required
|
||||
tabIndex={4}
|
||||
autoComplete="new-password"
|
||||
value={data.password_confirmation}
|
||||
onChange={(e) => setData('password_confirmation', e.target.value)}
|
||||
disabled={processing}
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
<InputError message={errors.password_confirmation} />
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="mt-2 w-full" tabIndex={5} disabled={processing}>
|
||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||
Create account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<TextLink href={route('login')} tabIndex={6}>
|
||||
Log in
|
||||
</TextLink>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { FormEventHandler } from 'react';
|
||||
|
||||
import InputError from '@/components/InputError';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
interface ResetPasswordProps {
|
||||
token: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
type ResetPasswordForm = {
|
||||
token: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
};
|
||||
|
||||
export default function ResetPassword({ token, email }: ResetPasswordProps) {
|
||||
const { data, setData, post, processing, errors, reset } = useForm<Required<ResetPasswordForm>>({
|
||||
token: token,
|
||||
email: email,
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
post(route('password.store'), {
|
||||
onFinish: () => reset('password', 'password_confirmation'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Reset password" description="Please enter your new password below">
|
||||
<Head title="Reset password" />
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
value={data.email}
|
||||
className="mt-1 block w-full"
|
||||
readOnly
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
/>
|
||||
<InputError message={errors.email} className="mt-2" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="new-password"
|
||||
value={data.password}
|
||||
className="mt-1 block w-full"
|
||||
autoFocus
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">Confirm password</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
type="password"
|
||||
name="password_confirmation"
|
||||
autoComplete="new-password"
|
||||
value={data.password_confirmation}
|
||||
className="mt-1 block w-full"
|
||||
onChange={(e) => setData('password_confirmation', e.target.value)}
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
<InputError message={errors.password_confirmation} className="mt-2" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="mt-4 w-full" disabled={processing}>
|
||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||
Reset password
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
// Components
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { FormEventHandler } from 'react';
|
||||
|
||||
import TextLink from '@/components/TextLink';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
export default function VerifyEmail({ status }: { status?: string }) {
|
||||
const { post, processing } = useForm({});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
post(route('verification.send'));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Verify email" description="Please verify your email address by clicking on the link we just emailed to you.">
|
||||
<Head title="Email verification" />
|
||||
|
||||
{status === 'verification-link-sent' && (
|
||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||
A new verification link has been sent to the email address you provided during registration.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={submit} className="space-y-6 text-center">
|
||||
<Button disabled={processing} variant="secondary">
|
||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||
Resend verification email
|
||||
</Button>
|
||||
|
||||
<TextLink href={route('logout')} method="post" className="mx-auto block text-sm">
|
||||
Log out
|
||||
</TextLink>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import StatsBox from '@/components/Display/StatsBox';
|
|||
import OnboardingFlow from '@/components/Onboarding/OnboardingFlow';
|
||||
import TerminalSpinner from '@/components/ui/TerminalSpinner';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface PurchaseSummary {
|
||||
total_shares: number;
|
||||
|
|
@ -23,6 +23,20 @@ interface Milestone {
|
|||
created_at: string;
|
||||
}
|
||||
|
||||
interface TrackerAsset {
|
||||
id: number;
|
||||
symbol: string;
|
||||
full_name: string | null;
|
||||
}
|
||||
|
||||
interface Tracker {
|
||||
id: number;
|
||||
label: string;
|
||||
unit: string;
|
||||
price_tracking_enabled: boolean;
|
||||
asset: TrackerAsset | null;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [purchaseData, setPurchaseData] = useState<PurchaseSummary>({
|
||||
total_shares: 0,
|
||||
|
|
@ -41,23 +55,32 @@ export default function Dashboard() {
|
|||
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | 'price' | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||
const [currentAsset, setCurrentAsset] = useState<any>(null);
|
||||
const [tracker, setTracker] = useState<Tracker | null>(null);
|
||||
const [currentAsset, setCurrentAsset] = useState<TrackerAsset | null>(null);
|
||||
const [priceTrackingEnabled, setPriceTrackingEnabled] = useState(false);
|
||||
|
||||
// Fetch purchase summary, current price, milestones, and check onboarding
|
||||
// Fetch entry summary, current price, milestones, and check onboarding
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [purchaseResponse, priceResponse, milestonesResponse, assetResponse] = await Promise.all([
|
||||
fetch('/purchases/summary'),
|
||||
const [entriesResponse, priceResponse, milestonesResponse, trackerResponse] = await Promise.all([
|
||||
fetch('/entries/summary'),
|
||||
fetch('/pricing/current'),
|
||||
fetch('/milestones'),
|
||||
fetch('/assets/current'),
|
||||
fetch('/tracker'),
|
||||
]);
|
||||
|
||||
if (purchaseResponse.ok) {
|
||||
const purchases = await purchaseResponse.json();
|
||||
setPurchaseData(purchases);
|
||||
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) {
|
||||
|
|
@ -68,16 +91,17 @@ export default function Dashboard() {
|
|||
if (milestonesResponse.ok) {
|
||||
const milestonesData = await milestonesResponse.json();
|
||||
setMilestones(milestonesData);
|
||||
milestonesCount = milestonesData.length;
|
||||
}
|
||||
|
||||
if (assetResponse.ok) {
|
||||
const assetData = await assetResponse.json();
|
||||
setCurrentAsset(assetData.asset);
|
||||
setPriceTrackingEnabled(assetData.price_tracking_enabled ?? false);
|
||||
if (trackerResponse.ok) {
|
||||
const trackerData = await trackerResponse.json();
|
||||
setTracker(trackerData);
|
||||
setCurrentAsset(trackerData?.asset ?? null);
|
||||
setPriceTrackingEnabled(trackerData?.price_tracking_enabled ?? false);
|
||||
}
|
||||
|
||||
// Check if onboarding is needed after all data is loaded
|
||||
await checkOnboardingStatus();
|
||||
setNeedsOnboarding(totalQuantity === 0 || milestonesCount === 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
} finally {
|
||||
|
|
@ -88,43 +112,20 @@ export default function Dashboard() {
|
|||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Check if user needs onboarding
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
const [assetResponse, purchaseResponse, milestonesResponse] = await Promise.all([
|
||||
fetch('/assets/current'),
|
||||
fetch('/purchases/summary'),
|
||||
fetch('/milestones'),
|
||||
]);
|
||||
|
||||
const assetData = await assetResponse.json();
|
||||
const purchaseData = await purchaseResponse.json();
|
||||
const milestonesData = await milestonesResponse.json();
|
||||
|
||||
const hasAsset = !!assetData.asset;
|
||||
const hasPurchases = purchaseData.total_shares > 0;
|
||||
const hasMilestones = milestonesData.length > 0;
|
||||
|
||||
// User needs onboarding if any required step is missing
|
||||
const needsOnboarding = !hasPurchases || !hasMilestones;
|
||||
setNeedsOnboarding(needsOnboarding);
|
||||
} catch (error) {
|
||||
console.error('Failed to check onboarding status:', error);
|
||||
// If we can't check, assume onboarding is needed
|
||||
setNeedsOnboarding(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh data after successful purchase
|
||||
// Refresh data after successful entry
|
||||
const handlePurchaseSuccess = async () => {
|
||||
try {
|
||||
const purchaseResponse = await fetch('/purchases/summary');
|
||||
if (purchaseResponse.ok) {
|
||||
const purchases = await purchaseResponse.json();
|
||||
setPurchaseData(purchases);
|
||||
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,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh purchase data:', error);
|
||||
console.error('Failed to refresh entry data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -210,21 +211,25 @@ export default function Dashboard() {
|
|||
};
|
||||
|
||||
// Handle onboarding completion
|
||||
const handleOnboardingComplete = async () => {
|
||||
// Refresh all data and check onboarding status
|
||||
await checkOnboardingStatus();
|
||||
|
||||
// Refresh individual data sets
|
||||
const [purchaseResponse, priceResponse, milestonesResponse, assetResponse] = await Promise.all([
|
||||
fetch('/purchases/summary'),
|
||||
const handleOnboardingComplete = useCallback(async () => {
|
||||
const [entriesResponse, priceResponse, milestonesResponse, trackerResponse] = await Promise.all([
|
||||
fetch('/entries/summary'),
|
||||
fetch('/pricing/current'),
|
||||
fetch('/milestones'),
|
||||
fetch('/assets/current'),
|
||||
fetch('/tracker'),
|
||||
]);
|
||||
|
||||
if (purchaseResponse.ok) {
|
||||
const purchases = await purchaseResponse.json();
|
||||
setPurchaseData(purchases);
|
||||
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) {
|
||||
|
|
@ -235,14 +240,17 @@ export default function Dashboard() {
|
|||
if (milestonesResponse.ok) {
|
||||
const milestonesData = await milestonesResponse.json();
|
||||
setMilestones(milestonesData);
|
||||
milestonesCount = milestonesData.length;
|
||||
}
|
||||
|
||||
if (assetResponse.ok) {
|
||||
const assetData = await assetResponse.json();
|
||||
setCurrentAsset(assetData.asset);
|
||||
setPriceTrackingEnabled(assetData.price_tracking_enabled ?? false);
|
||||
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) {
|
||||
|
|
@ -265,6 +273,7 @@ export default function Dashboard() {
|
|||
<div className="pt-32">
|
||||
<LedDisplay
|
||||
value={purchaseData.total_shares}
|
||||
unit={tracker?.unit}
|
||||
onClick={handleLedClick}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -272,7 +281,7 @@ export default function Dashboard() {
|
|||
{/* Box 2: Progress Bar (toggleable) */}
|
||||
<div style={{ display: showProgressBar ? 'block' : 'none' }}>
|
||||
<ProgressBar
|
||||
currentShares={purchaseData.total_shares}
|
||||
currentQuantity={purchaseData.total_shares}
|
||||
milestones={milestones}
|
||||
selectedMilestoneIndex={selectedMilestoneIndex}
|
||||
onClick={handleProgressClick}
|
||||
|
|
@ -283,6 +292,7 @@ export default function Dashboard() {
|
|||
<div style={{ display: showStatsBox ? 'block' : 'none' }}>
|
||||
<StatsBox
|
||||
stats={statsData}
|
||||
unit={tracker?.unit}
|
||||
milestones={milestones}
|
||||
selectedMilestoneIndex={selectedMilestoneIndex}
|
||||
onMilestoneSelect={handleMilestoneSelect}
|
||||
|
|
@ -298,6 +308,8 @@ export default function Dashboard() {
|
|||
<div style={{ display: activeForm && showProgressBar && showStatsBox ? 'block' : 'none' }}>
|
||||
<InlineForm
|
||||
type={activeForm}
|
||||
unit={tracker?.unit}
|
||||
priceTrackingEnabled={priceTrackingEnabled}
|
||||
onClose={() => setActiveForm(null)}
|
||||
onPurchaseSuccess={handlePurchaseSuccess}
|
||||
onMilestoneSuccess={handleMilestoneSuccess}
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import { Head } from '@inertiajs/react';
|
||||
|
||||
import AppearanceTabs from '@/components/Settings/AppearanceTabs';
|
||||
import HeadingSmall from '@/components/HeadingSmall';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Appearance settings',
|
||||
href: '/settings/appearance',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Appearance() {
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Appearance settings" />
|
||||
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
<HeadingSmall title="Appearance settings" description="Update your account's appearance settings" />
|
||||
<AppearanceTabs />
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
import InputError from '@/components/InputError';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { FormEventHandler, useRef } from 'react';
|
||||
|
||||
import HeadingSmall from '@/components/HeadingSmall';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Password settings',
|
||||
href: '/settings/password',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Password() {
|
||||
const passwordInput = useRef<HTMLInputElement>(null);
|
||||
const currentPasswordInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data, setData, errors, put, reset, processing, recentlySuccessful } = useForm({
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
const updatePassword: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
put(route('password.update'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => reset(),
|
||||
onError: (errors) => {
|
||||
if (errors.password) {
|
||||
reset('password', 'password_confirmation');
|
||||
passwordInput.current?.focus();
|
||||
}
|
||||
|
||||
if (errors.current_password) {
|
||||
reset('current_password');
|
||||
currentPasswordInput.current?.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Password settings" />
|
||||
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
<HeadingSmall title="Update password" description="Ensure your account is using a long, random password to stay secure" />
|
||||
|
||||
<form onSubmit={updatePassword} className="space-y-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="current_password">Current password</Label>
|
||||
|
||||
<Input
|
||||
id="current_password"
|
||||
ref={currentPasswordInput}
|
||||
value={data.current_password}
|
||||
onChange={(e) => setData('current_password', e.target.value)}
|
||||
type="password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="current-password"
|
||||
placeholder="Current password"
|
||||
/>
|
||||
|
||||
<InputError message={errors.current_password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">New password</Label>
|
||||
|
||||
<Input
|
||||
id="password"
|
||||
ref={passwordInput}
|
||||
value={data.password}
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
type="password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="new-password"
|
||||
placeholder="New password"
|
||||
/>
|
||||
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">Confirm password</Label>
|
||||
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
value={data.password_confirmation}
|
||||
onChange={(e) => setData('password_confirmation', e.target.value)}
|
||||
type="password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="new-password"
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
|
||||
<InputError message={errors.password_confirmation} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button disabled={processing}>Save password</Button>
|
||||
|
||||
<Transition
|
||||
show={recentlySuccessful}
|
||||
enter="transition ease-in-out"
|
||||
enterFrom="opacity-0"
|
||||
leave="transition ease-in-out"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<p className="text-sm text-neutral-600">Saved</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
import { type BreadcrumbItem, type SharedData } from '@/types';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||
import { FormEventHandler } from 'react';
|
||||
|
||||
import DeleteUser from '@/components/Settings/DeleteUser';
|
||||
import HeadingSmall from '@/components/HeadingSmall';
|
||||
import InputError from '@/components/InputError';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Profile settings',
|
||||
href: '/settings/profile',
|
||||
},
|
||||
];
|
||||
|
||||
type ProfileForm = {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail: boolean; status?: string }) {
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
|
||||
const { data, setData, patch, errors, processing, recentlySuccessful } = useForm<Required<ProfileForm>>({
|
||||
name: auth.user.name,
|
||||
email: auth.user.email,
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
patch(route('profile.update'), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Profile settings" />
|
||||
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
<HeadingSmall title="Profile information" description="Update your name and email address" />
|
||||
|
||||
<form onSubmit={submit} className="space-y-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
|
||||
<Input
|
||||
id="name"
|
||||
className="mt-1 block w-full"
|
||||
value={data.name}
|
||||
onChange={(e) => setData('name', e.target.value)}
|
||||
required
|
||||
autoComplete="name"
|
||||
placeholder="Full name"
|
||||
/>
|
||||
|
||||
<InputError className="mt-2" message={errors.name} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
className="mt-1 block w-full"
|
||||
value={data.email}
|
||||
onChange={(e) => setData('email', e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
placeholder="Email address"
|
||||
/>
|
||||
|
||||
<InputError className="mt-2" message={errors.email} />
|
||||
</div>
|
||||
|
||||
{mustVerifyEmail && auth.user.email_verified_at === null && (
|
||||
<div>
|
||||
<p className="-mt-4 text-sm text-muted-foreground">
|
||||
Your email address is unverified.{' '}
|
||||
<Link
|
||||
href={route('verification.send')}
|
||||
method="post"
|
||||
as="button"
|
||||
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
|
||||
>
|
||||
Click here to resend the verification email.
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{status === 'verification-link-sent' && (
|
||||
<div className="mt-2 text-sm font-medium text-green-600">
|
||||
A new verification link has been sent to your email address.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button disabled={processing}>Save</Button>
|
||||
|
||||
<Transition
|
||||
show={recentlySuccessful}
|
||||
enter="transition ease-in-out"
|
||||
enterFrom="opacity-0"
|
||||
leave="transition ease-in-out"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<p className="text-sm text-neutral-600">Saved</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<DeleteUser />
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,56 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Auth\AuthenticatedSessionController;
|
||||
use App\Http\Controllers\Auth\ConfirmablePasswordController;
|
||||
use App\Http\Controllers\Auth\EmailVerificationNotificationController;
|
||||
use App\Http\Controllers\Auth\EmailVerificationPromptController;
|
||||
use App\Http\Controllers\Auth\NewPasswordController;
|
||||
use App\Http\Controllers\Auth\PasswordResetLinkController;
|
||||
use App\Http\Controllers\Auth\RegisteredUserController;
|
||||
use App\Http\Controllers\Auth\VerifyEmailController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware('guest')->group(function () {
|
||||
Route::get('register', [RegisteredUserController::class, 'create'])
|
||||
->name('register');
|
||||
|
||||
Route::post('register', [RegisteredUserController::class, 'store']);
|
||||
|
||||
Route::get('login', [AuthenticatedSessionController::class, 'create'])
|
||||
->name('login');
|
||||
|
||||
Route::post('login', [AuthenticatedSessionController::class, 'store']);
|
||||
|
||||
Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])
|
||||
->name('password.request');
|
||||
|
||||
Route::post('forgot-password', [PasswordResetLinkController::class, 'store'])
|
||||
->name('password.email');
|
||||
|
||||
Route::get('reset-password/{token}', [NewPasswordController::class, 'create'])
|
||||
->name('password.reset');
|
||||
|
||||
Route::post('reset-password', [NewPasswordController::class, 'store'])
|
||||
->name('password.store');
|
||||
});
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('verify-email', EmailVerificationPromptController::class)
|
||||
->name('verification.notice');
|
||||
|
||||
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
|
||||
->middleware(['signed', 'throttle:6,1'])
|
||||
->name('verification.verify');
|
||||
|
||||
Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
|
||||
->middleware('throttle:6,1')
|
||||
->name('verification.send');
|
||||
|
||||
Route::get('confirm-password', [ConfirmablePasswordController::class, 'show'])
|
||||
->name('password.confirm');
|
||||
|
||||
Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']);
|
||||
|
||||
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
|
||||
->name('logout');
|
||||
});
|
||||
// First-run setup only — gated by User::exists() in the controller
|
||||
Route::get('register', [RegisteredUserController::class, 'create'])->name('register');
|
||||
Route::post('register', [RegisteredUserController::class, 'store']);
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Settings\PasswordController;
|
||||
use App\Http\Controllers\Settings\ProfileController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::redirect('settings', '/settings/profile');
|
||||
|
||||
Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
|
||||
Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
||||
|
||||
Route::get('settings/password', [PasswordController::class, 'edit'])->name('password.edit');
|
||||
Route::put('settings/password', [PasswordController::class, 'update'])->name('password.update');
|
||||
|
||||
Route::get('settings/appearance', function () {
|
||||
return Inertia::render('settings/appearance');
|
||||
})->name('appearance');
|
||||
});
|
||||
|
|
@ -3,7 +3,8 @@
|
|||
use App\Http\Controllers\AssetController;
|
||||
use App\Http\Controllers\Milestones\MilestoneController;
|
||||
use App\Http\Controllers\Pricing\PricingController;
|
||||
use App\Http\Controllers\Transactions\PurchaseController;
|
||||
use App\Http\Controllers\TrackerController;
|
||||
use App\Http\Controllers\Transactions\EntryController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
|
||||
|
|
@ -15,22 +16,25 @@
|
|||
return Inertia::render('dashboard');
|
||||
})->name('dashboard');
|
||||
|
||||
// Tracker routes
|
||||
Route::get('/tracker', [TrackerController::class, 'show'])->name('tracker.show');
|
||||
Route::post('/tracker', [TrackerController::class, 'store'])->name('tracker.store');
|
||||
Route::patch('/tracker', [TrackerController::class, 'update'])->name('tracker.update');
|
||||
|
||||
// Asset routes
|
||||
Route::prefix('assets')->name('assets.')->group(function () {
|
||||
Route::get('/', [AssetController::class, 'index'])->name('index');
|
||||
Route::post('/', [AssetController::class, 'store'])->name('store');
|
||||
Route::get('/current', [AssetController::class, 'current'])->name('current');
|
||||
Route::post('/set-current', [AssetController::class, 'setCurrent'])->name('set-current');
|
||||
Route::get('/search', [AssetController::class, 'search'])->name('search');
|
||||
Route::get('/{asset}', [AssetController::class, 'show'])->name('show');
|
||||
});
|
||||
|
||||
// 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');
|
||||
// Entry routes (replaces purchases)
|
||||
Route::prefix('entries')->name('entries.')->group(function () {
|
||||
Route::get('/', [EntryController::class, 'index'])->name('index');
|
||||
Route::post('/', [EntryController::class, 'store'])->name('store');
|
||||
Route::get('/summary', [EntryController::class, 'summary'])->name('summary');
|
||||
Route::delete('/{entry}', [EntryController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Pricing routes
|
||||
|
|
@ -47,5 +51,4 @@
|
|||
Route::post('/', [MilestoneController::class, 'store'])->name('store');
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
require __DIR__.'/auth.php';
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Milestone;
|
||||
use App\Models\Tracker;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -10,9 +12,21 @@ 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',
|
||||
]);
|
||||
|
|
@ -28,9 +42,9 @@ public function test_can_create_milestone(): void
|
|||
|
||||
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']);
|
||||
$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');
|
||||
|
||||
|
|
@ -44,10 +58,10 @@ public function test_can_fetch_milestones_via_api(): void
|
|||
|
||||
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']);
|
||||
$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');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue