Compare commits
No commits in common. "bdfb7c031c2ccd656c0cdf0ba48276b33bc56f8c" and "7c1968415951d4e6914770417624fc84d44732a9" have entirely different histories.
bdfb7c031c
...
7c19684159
96 changed files with 5391 additions and 12645 deletions
|
|
@ -1,13 +0,0 @@
|
||||||
node_modules
|
|
||||||
vendor
|
|
||||||
.git
|
|
||||||
.forgejo
|
|
||||||
docker/dev
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
.env.testing
|
|
||||||
storage/logs/*
|
|
||||||
storage/framework/cache/*
|
|
||||||
storage/framework/sessions/*
|
|
||||||
storage/framework/views/*
|
|
||||||
tests
|
|
||||||
17
.env.example
17
.env.example
|
|
@ -1,7 +1,7 @@
|
||||||
APP_NAME=Laravel
|
APP_NAME=Laravel
|
||||||
APP_ENV=production
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=false
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
|
|
@ -18,18 +18,18 @@ BCRYPT_ROUNDS=12
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stack
|
||||||
LOG_STACK=single
|
LOG_STACK=single
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=error
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_DATABASE=incr
|
DB_DATABASE=incr
|
||||||
DB_USERNAME=incr_user
|
DB_USERNAME=incr_user
|
||||||
DB_PASSWORD=change_me_in_production
|
DB_PASSWORD=incr_password
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=true
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
|
|
@ -40,6 +40,13 @@ QUEUE_CONNECTION=database
|
||||||
CACHE_STORE=database
|
CACHE_STORE=database
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
|
REDIS_CLIENT=phpredis
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=log
|
||||||
MAIL_SCHEME=null
|
MAIL_SCHEME=null
|
||||||
MAIL_HOST=127.0.0.1
|
MAIL_HOST=127.0.0.1
|
||||||
|
|
|
||||||
11
.env.testing
11
.env.testing
|
|
@ -1,11 +0,0 @@
|
||||||
APP_ENV=testing
|
|
||||||
APP_KEY=base64:+7T2RuonhTIij1yLp3rTOv2uQlYJh0TQulu20MlCA+s=
|
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
|
||||||
DB_HOST=db
|
|
||||||
DB_DATABASE=testing
|
|
||||||
DB_USERNAME=incr_user
|
|
||||||
DB_PASSWORD=incr_password
|
|
||||||
|
|
||||||
SESSION_DRIVER=array
|
|
||||||
CACHE_STORE=array
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
name: Build and Push Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
tags: ['v*']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: docker
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: https://data.forgejo.org/docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: https://data.forgejo.org/docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to Forgejo Registry
|
|
||||||
uses: https://data.forgejo.org/docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: forge.lvl0.xyz
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
|
|
||||||
- name: Determine tags
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
|
||||||
TAG="${{ github.ref_name }}"
|
|
||||||
echo "tags=forge.lvl0.xyz/lvl0/incr:${TAG},forge.lvl0.xyz/lvl0/incr:latest" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "tags=forge.lvl0.xyz/lvl0/incr:latest" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: https://data.forgejo.org/docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/production/Dockerfile
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ['release/*']
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
runs-on: docker
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
|
|
||||||
services:
|
|
||||||
db:
|
|
||||||
image: mysql:8.0
|
|
||||||
env:
|
|
||||||
MYSQL_DATABASE: testing
|
|
||||||
MYSQL_USER: incr_user
|
|
||||||
MYSQL_PASSWORD: incr_password
|
|
||||||
MYSQL_ROOT_PASSWORD: root_password
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up PHP
|
|
||||||
uses: https://github.com/shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.3'
|
|
||||||
extensions: pdo_mysql, mbstring, xml, dom, bcmath, gd, exif, pcntl
|
|
||||||
coverage: pcov
|
|
||||||
|
|
||||||
- name: Cache Composer dependencies
|
|
||||||
uses: https://data.forgejo.org/actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.composer/cache
|
|
||||||
key: composer-${{ hashFiles('composer.lock') }}
|
|
||||||
restore-keys: composer-
|
|
||||||
|
|
||||||
- name: Install PHP dependencies
|
|
||||||
run: composer install --no-interaction --prefer-dist --no-progress
|
|
||||||
|
|
||||||
- name: Prepare environment
|
|
||||||
run: cp .env.testing .env
|
|
||||||
|
|
||||||
- name: Wait for MySQL
|
|
||||||
run: |
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
nc -z db 3306 && echo "MySQL is up" && break
|
|
||||||
echo "Waiting for MySQL... ($i/30)"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Run migrations
|
|
||||||
run: php artisan migrate --force
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: vendor/bin/pint --test
|
|
||||||
|
|
||||||
- name: Tests
|
|
||||||
run: php artisan test --coverage-clover coverage.xml --coverage-text
|
|
||||||
|
|
||||||
- name: Parse coverage
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
id: coverage
|
|
||||||
run: |
|
|
||||||
COVERAGE=$(php -r '
|
|
||||||
$xml = simplexml_load_file("coverage.xml");
|
|
||||||
if ($xml === false || !isset($xml->project->metrics)) {
|
|
||||||
echo "0";
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
$metrics = $xml->project->metrics;
|
|
||||||
$statements = (int) $metrics["statements"];
|
|
||||||
$covered = (int) $metrics["coveredstatements"];
|
|
||||||
echo $statements > 0 ? round(($covered / $statements) * 100, 2) : 0;
|
|
||||||
')
|
|
||||||
echo "percentage=$COVERAGE" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Comment coverage on PR
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
COVERAGE: ${{ steps.coverage.outputs.percentage }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
SERVER_URL: ${{ github.server_url }}
|
|
||||||
COMMIT_SHA: ${{ github.sha }}
|
|
||||||
run: |
|
|
||||||
API_URL="${SERVER_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/comments"
|
|
||||||
MARKER="<!-- incr-ci-coverage-report -->"
|
|
||||||
|
|
||||||
BODY="${MARKER}
|
|
||||||
## Code Coverage Report
|
|
||||||
|
|
||||||
| Metric | Value |
|
|
||||||
|--------|-------|
|
|
||||||
| **Line Coverage** | ${COVERAGE}% |
|
|
||||||
|
|
||||||
_Updated by CI — commit ${COMMIT_SHA}_"
|
|
||||||
|
|
||||||
EXISTING=$(curl -sf -H "Authorization: token ${FORGEJO_TOKEN}" \
|
|
||||||
"${API_URL}?limit=50" | \
|
|
||||||
php -r '
|
|
||||||
$comments = json_decode(file_get_contents("php://stdin"), true);
|
|
||||||
if (!is_array($comments)) exit;
|
|
||||||
foreach ($comments as $c) {
|
|
||||||
if (str_contains($c["body"], "<!-- incr-ci-coverage-report -->")) {
|
|
||||||
echo $c["id"];
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
' || true)
|
|
||||||
|
|
||||||
if [ -n "$EXISTING" ]; then
|
|
||||||
curl -sf -X PATCH \
|
|
||||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
|
|
||||||
"${SERVER_URL}/api/v1/repos/${REPO}/issues/comments/${EXISTING}" > /dev/null
|
|
||||||
else
|
|
||||||
curl -sf -X POST \
|
|
||||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
|
|
||||||
"${API_URL}" > /dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
build:
|
|
||||||
runs-on: docker
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install JS dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build assets
|
|
||||||
run: npm run build
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,6 +12,7 @@
|
||||||
.env.production
|
.env.production
|
||||||
.phpactor.json
|
.phpactor.json
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
/composer.lock
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
|
|
||||||
201
README.md
201
README.md
|
|
@ -1,108 +1,157 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
# incr
|
# 📈 incr
|
||||||
|
|
||||||
**A minimalist counter with milestone-driven progress**
|
**A minimalist investment tracker for VWCE shares with milestone-driven progress**
|
||||||
|
|
||||||
*Track anything you accumulate — with visual progress and milestone reinforcement*
|
*Track your portfolio growth with visual progress indicators and milestone reinforcement*
|
||||||
|
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
|
[](https://www.docker.com/)
|
||||||
[](https://laravel.com/)
|
[](https://laravel.com/)
|
||||||
[](https://reactjs.org/)
|
[](https://reactjs.org/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[Introduction](#introduction) • [Features](#features) • [Tech Stack](#tech-stack) • [Getting Started](#getting-started) • [Development](#development) • [Contributing](#contributing) • [License](#license)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## About
|
## Introduction
|
||||||
|
|
||||||
incr is a minimalist, self-hosted tracking app. Pick something you want to accumulate — shares, books, workouts, anything — log your progress, and watch milestones fall.
|
Incr is a minimalist, one-page investment tracking application designed specifically for VWCE (Vanguard FTSE All-World UCITS ETF) shareholders. It combines the satisfaction of visual progress tracking with practical portfolio management, featuring a distinctive LED-style digital display and milestone-based goal setting.
|
||||||
|
|
||||||
It features a distinctive LED-style digital display, configurable milestones, and optional price tracking.
|
The application emphasizes simplicity and focus, providing just what you need to track your investment journey without overwhelming complexity.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **LED-style display** — large red digital counter
|
- **LED-style display**: Large red digital counter showing current share count
|
||||||
- **Milestone tracking** — set targets, cycle through them as you progress
|
- **Progress tracking**: Visual progress bar toward configurable milestones
|
||||||
- **Purchase logging** — add entries with date and optional price
|
- **Purchase management**: Add and track share purchases with historical data
|
||||||
- **Price tracking** — optional; log a current price to see portfolio value and P/L
|
- **Financial insights**: Portfolio value and withdrawal estimates
|
||||||
- **Self-hosted** — your data stays on your server
|
- **Milestone cycling**: Track progress toward multiple investment goals (1500→3000→4500→6000)
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Laravel 12 (PHP 8.3+) with MySQL
|
- **Backend**: Laravel 12 (PHP 8.2+) with MySQL database
|
||||||
- **Frontend**: React 19 + TypeScript with Inertia.js
|
- **Frontend**: React 19 + TypeScript with Inertia.js
|
||||||
- **Styling**: Tailwind CSS 4 with shadcn/ui components
|
- **Styling**: Tailwind CSS 4 with shadcn/ui components
|
||||||
- **Deployment**: Docker / Podman with multi-stage builds
|
- **Deployment**: Docker with multi-stage builds
|
||||||
|
|
||||||
## Self-hosting
|
## Getting Started
|
||||||
|
|
||||||
```yaml
|
### Quick Start (Production)
|
||||||
# docker-compose.yml
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
image: forge.lvl0.xyz/lvl0/incr:latest
|
|
||||||
container_name: incr-app
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- APP_ENV=production
|
|
||||||
- APP_DEBUG=false
|
|
||||||
- APP_KEY=base64:YOUR_APP_KEY_HERE
|
|
||||||
- DB_CONNECTION=mysql
|
|
||||||
- DB_HOST=db
|
|
||||||
- DB_PORT=3306
|
|
||||||
- DB_DATABASE=incr
|
|
||||||
- DB_USERNAME=incr_user
|
|
||||||
- DB_PASSWORD=change_me
|
|
||||||
ports:
|
|
||||||
- "5001:80"
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- incr-network
|
|
||||||
|
|
||||||
db:
|
#### Docker
|
||||||
image: mysql:8.0
|
|
||||||
container_name: incr-db
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- MYSQL_DATABASE=incr
|
|
||||||
- MYSQL_USER=incr_user
|
|
||||||
- MYSQL_PASSWORD=change_me
|
|
||||||
- MYSQL_ROOT_PASSWORD=change_me_root
|
|
||||||
volumes:
|
|
||||||
- db_data:/var/lib/mysql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "incr_user", "-pchange_me"]
|
|
||||||
timeout: 10s
|
|
||||||
retries: 10
|
|
||||||
interval: 10s
|
|
||||||
start_period: 10s
|
|
||||||
networks:
|
|
||||||
- incr-network
|
|
||||||
|
|
||||||
networks:
|
Clone the repository and run with Docker Compose:
|
||||||
incr-network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
db_data:
|
|
||||||
```
|
|
||||||
|
|
||||||
Generate an app key with: `php artisan key:generate --show`
|
|
||||||
|
|
||||||
The app will be available at `http://localhost:5001`.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
Requires [Nix](https://nixos.org/download/). Enter the dev shell:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix-shell
|
git clone https://github.com/your-username/incr.git
|
||||||
dev-up
|
cd incr
|
||||||
```
|
```
|
||||||
|
|
||||||
Available commands inside the shell: `dev-up`, `dev-down`, `dev-rebuild`, `dev-logs`, `dev-shell`, `dev-artisan`, `dev-composer`.
|
Run the application using the provided docker-compose configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using Docker Compose
|
||||||
|
docker-compose -f docker/production/docker-compose.yml up --build
|
||||||
|
|
||||||
|
# Or using Podman Compose
|
||||||
|
podman-compose -f docker/production/docker-compose.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at `http://localhost:5001`.
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
#### Local Development Setup
|
||||||
|
|
||||||
|
**Option 1: Laravel Sail (Docker)**
|
||||||
|
|
||||||
|
For local development with Laravel Sail:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Laravel Sail
|
||||||
|
composer install
|
||||||
|
sail artisan sail:install
|
||||||
|
|
||||||
|
# Start development environment
|
||||||
|
sail up -d
|
||||||
|
|
||||||
|
# Install frontend dependencies and build assets
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
sail artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Podman Development**
|
||||||
|
|
||||||
|
For Fedora Atomic or other Podman-based systems:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Quick start with helper script
|
||||||
|
bash docker/dev/podman/start-dev.sh
|
||||||
|
|
||||||
|
# Or manually:
|
||||||
|
# Install podman-compose if not available
|
||||||
|
pip3 install --user podman-compose
|
||||||
|
|
||||||
|
# Start development environment
|
||||||
|
podman-compose -f docker/dev/podman/docker-compose.yml up -d
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
podman exec incr-dev-app php artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 3: Sail with Podman (Compatibility Layer)**
|
||||||
|
|
||||||
|
To use Laravel Sail commands with Podman:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Source the alias script
|
||||||
|
source docker/dev/podman/podman-sail-alias.sh
|
||||||
|
|
||||||
|
# Now you can use sail commands as normal
|
||||||
|
sail up -d
|
||||||
|
sail artisan migrate
|
||||||
|
sail npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The development server will be available at `http://localhost` with hot reload enabled.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `app/` - Laravel backend (controllers, models, services)
|
||||||
|
- `resources/js/` - React frontend components and pages
|
||||||
|
- `docker/production/` - Production Docker configuration
|
||||||
|
- `docker/dev/podman/` - Development Podman configuration
|
||||||
|
- `database/migrations/` - Database schema definitions
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
We welcome contributions to incr! Whether you're reporting bugs, suggesting features, or submitting pull requests, your input helps make this project better.
|
||||||
|
|
||||||
|
### How to Contribute
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
### Bug Reports
|
||||||
|
|
||||||
|
If you find a bug, please create an issue with:
|
||||||
|
- A clear description of the problem
|
||||||
|
- Steps to reproduce the issue
|
||||||
|
- Expected vs actual behavior
|
||||||
|
- Your environment details
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
GNU General Public License v3.0 — see [LICENSE](LICENSE).
|
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,60 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Asset;
|
use App\Models\Asset;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class AssetController extends Controller
|
class AssetController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json(Asset::orderBy('symbol')->get());
|
$assets = Asset::orderBy('symbol')->get();
|
||||||
|
|
||||||
|
return response()->json($assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function current(): JsonResponse
|
||||||
|
{
|
||||||
|
// Get the first/default user (since no auth)
|
||||||
|
$user = \App\Models\User::first();
|
||||||
|
$asset = $user ? $user->asset : null;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'asset' => $asset,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = \App\Models\User::first();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
// Create a default user if none exists
|
||||||
|
$user = \App\Models\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!');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
|
|
@ -37,18 +79,19 @@ public function store(Request $request): JsonResponse
|
||||||
public function show(Asset $asset): JsonResponse
|
public function show(Asset $asset): JsonResponse
|
||||||
{
|
{
|
||||||
$asset->load('assetPrices');
|
$asset->load('assetPrices');
|
||||||
|
$currentPrice = $asset->currentPrice();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'asset' => $asset,
|
'asset' => $asset,
|
||||||
'current_price' => $asset->currentPrice(),
|
'current_price' => $currentPrice,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function search(Request $request): JsonResponse
|
public function search(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$query = $request->get('q');
|
$query = $request->get('q');
|
||||||
|
|
||||||
if (! $query) {
|
if (!$query) {
|
||||||
return response()->json([]);
|
return response()->json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,4 +103,4 @@ public function search(Request $request): JsonResponse
|
||||||
|
|
||||||
return response()->json($assets);
|
return response()->json($assets);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
51
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
51
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?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('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
41
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?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')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
70
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<?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 \Illuminate\Validation\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)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
41
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?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 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 \Illuminate\Validation\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.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Validation\Rules;
|
use Illuminate\Validation\Rules;
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
|
|
@ -21,34 +20,26 @@ class RegisteredUserController extends Controller
|
||||||
*/
|
*/
|
||||||
public function create(): Response
|
public function create(): Response
|
||||||
{
|
{
|
||||||
if (User::exists()) {
|
|
||||||
abort(403, 'Registration is disabled.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return Inertia::render('auth/register');
|
return Inertia::render('auth/register');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an incoming registration request.
|
* Handle an incoming registration request.
|
||||||
*
|
*
|
||||||
* @throws ValidationException
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
if (User::exists()) {
|
$request->validate([
|
||||||
abort(403, 'Registration is disabled.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
|
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
|
||||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::forceCreate([
|
$user = User::create([
|
||||||
'name' => $validated['name'],
|
'name' => $request->name,
|
||||||
'email' => $validated['email'],
|
'email' => $request->email,
|
||||||
'password' => Hash::make($validated['password']),
|
'password' => Hash::make($request->password),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
event(new Registered($user));
|
event(new Registered($user));
|
||||||
|
|
|
||||||
30
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
30
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Auth\Events\Verified;
|
||||||
|
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 \Illuminate\Contracts\Auth\MustVerifyEmail $user */
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
event(new Verified($user));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,43 +1,34 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Milestones;
|
namespace App\Http\Controllers\Milestones;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\Milestone;
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class MilestoneController extends Controller
|
class MilestoneController extends Controller
|
||||||
{
|
{
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$request->validate([
|
||||||
'target' => 'required|integer|min:1',
|
'target' => 'required|integer|min:1',
|
||||||
'description' => 'required|string|max:255',
|
'description' => 'required|string|max:255',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tracker = User::default()->tracker;
|
Milestone::create([
|
||||||
|
'target' => $request->target,
|
||||||
if (! $tracker) {
|
'description' => $request->description,
|
||||||
return back()->withErrors(['tracker' => 'No tracker found. Please complete onboarding first.']);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
$tracker->milestones()->create($validated);
|
|
||||||
|
|
||||||
return back()->with('success', 'Milestone created successfully');
|
return back()->with('success', 'Milestone created successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
$tracker = User::default()->tracker;
|
$milestones = Milestone::orderBy('target')->get();
|
||||||
|
|
||||||
if (! $tracker) {
|
return response()->json($milestones);
|
||||||
return response()->json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json($tracker->milestones()->orderBy('target')->get());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,29 +1,24 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Pricing;
|
namespace App\Http\Controllers\Pricing;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Pricing\AssetPrice;
|
use App\Models\Pricing\AssetPrice;
|
||||||
use App\Models\Tracker;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class PricingController extends Controller
|
class PricingController extends Controller
|
||||||
{
|
{
|
||||||
private ?Tracker $tracker;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->tracker = User::default()->tracker;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function current(): JsonResponse
|
public function current(): JsonResponse
|
||||||
{
|
{
|
||||||
|
// Get the first/default user (since no auth)
|
||||||
|
$user = \App\Models\User::first();
|
||||||
|
$assetId = $user ? $user->asset_id : null;
|
||||||
|
|
||||||
|
$price = AssetPrice::current($assetId);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'current_price' => AssetPrice::current($this->tracker?->asset_id),
|
'current_price' => $price,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,33 +29,41 @@ public function update(Request $request)
|
||||||
'price' => 'required|numeric|min:0.0001',
|
'price' => 'required|numeric|min:0.0001',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! $this->tracker?->asset_id) {
|
// Get the first/default user (since no auth)
|
||||||
|
$user = \App\Models\User::first();
|
||||||
|
|
||||||
|
if (!$user || !$user->asset_id) {
|
||||||
return back()->withErrors(['asset' => 'Please set an asset first.']);
|
return back()->withErrors(['asset' => 'Please set an asset first.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
AssetPrice::updatePrice($this->tracker->asset_id, $validated['date'], $validated['price']);
|
$assetPrice = AssetPrice::updatePrice($user->asset_id, $validated['date'], $validated['price']);
|
||||||
|
|
||||||
if (! $this->tracker->price_tracking_enabled) {
|
|
||||||
$this->tracker->update(['price_tracking_enabled' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return back()->with('success', 'Asset price updated successfully!');
|
return back()->with('success', 'Asset price updated successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function history(Request $request): JsonResponse
|
public function history(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$limit = min(max(1, $request->integer('limit', 30)), 365);
|
// Get the first/default user (since no auth)
|
||||||
|
$user = \App\Models\User::first();
|
||||||
|
$assetId = $user ? $user->asset_id : null;
|
||||||
|
|
||||||
|
$limit = $request->get('limit', 30);
|
||||||
|
$history = AssetPrice::history($assetId, $limit);
|
||||||
|
|
||||||
return response()->json(AssetPrice::history($this->tracker?->asset_id, $limit));
|
return response()->json($history);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function forDate(Request $request, string $date): JsonResponse
|
public function forDate(Request $request, string $date): JsonResponse
|
||||||
{
|
{
|
||||||
validator(['date' => $date], ['date' => 'required|date_format:Y-m-d'])->validate();
|
// Get the first/default user (since no auth)
|
||||||
|
$user = \App\Models\User::first();
|
||||||
|
$assetId = $user ? $user->asset_id : null;
|
||||||
|
|
||||||
|
$price = AssetPrice::forDate($date, $assetId);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'price' => AssetPrice::forDate($date, $this->tracker?->asset_id),
|
'price' => $price,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
39
app/Http/Controllers/Settings/PasswordController.php
Normal file
39
app/Http/Controllers/Settings/PasswordController.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Http/Controllers/Settings/ProfileController.php
Normal file
63
app/Http/Controllers/Settings/ProfileController.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?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('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Asset;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class TrackerController extends Controller
|
|
||||||
{
|
|
||||||
public function show(): JsonResponse
|
|
||||||
{
|
|
||||||
$tracker = User::default()->tracker;
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return response()->json(['exists' => false]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json(['exists' => true, 'tracker' => $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): RedirectResponse|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 back()->withErrors(['tracker' => 'No tracker found.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
<?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!',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
72
app/Http/Controllers/Transactions/PurchaseController.php
Normal file
72
app/Http/Controllers/Transactions/PurchaseController.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Transactions;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Transactions\Purchase;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Redirect;
|
||||||
|
|
||||||
|
class PurchaseController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$purchases = Purchase::orderBy('date', 'desc')->get();
|
||||||
|
|
||||||
|
return response()->json($purchases);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$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!',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ class HandleAppearance
|
||||||
/**
|
/**
|
||||||
* Handle an incoming request.
|
* Handle an incoming request.
|
||||||
*
|
*
|
||||||
* @param Closure(Request): (Response) $next
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ public function share(Request $request): array
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
'quote' => ['message' => trim($message), 'author' => trim($author)],
|
'quote' => ['message' => trim($message), 'author' => trim($author)],
|
||||||
'auth' => [
|
'auth' => [
|
||||||
'user' => $request->user()?->only(['id', 'name', 'email']),
|
'user' => $request->user(),
|
||||||
],
|
],
|
||||||
'ziggy' => fn (): array => [
|
'ziggy' => fn (): array => [
|
||||||
...(new Ziggy)->toArray(),
|
...(new Ziggy)->toArray(),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
namespace App\Http\Requests\Auth;
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
use Illuminate\Auth\Events\Lockout;
|
use Illuminate\Auth\Events\Lockout;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
|
@ -23,7 +22,7 @@ public function authorize(): bool
|
||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, ValidationRule|array<mixed>|string>
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
|
@ -36,7 +35,7 @@ public function rules(): array
|
||||||
/**
|
/**
|
||||||
* Attempt to authenticate the request's credentials.
|
* Attempt to authenticate the request's credentials.
|
||||||
*
|
*
|
||||||
* @throws ValidationException
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
*/
|
*/
|
||||||
public function authenticate(): void
|
public function authenticate(): void
|
||||||
{
|
{
|
||||||
|
|
@ -56,7 +55,7 @@ public function authenticate(): void
|
||||||
/**
|
/**
|
||||||
* Ensure the login request is not rate limited.
|
* Ensure the login request is not rate limited.
|
||||||
*
|
*
|
||||||
* @throws ValidationException
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
*/
|
*/
|
||||||
public function ensureIsNotRateLimited(): void
|
public function ensureIsNotRateLimited(): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
* @method static where(string $string, string $value)
|
* @method static where(string $string, string $value)
|
||||||
* @method static find(int $id)
|
* @method static find(int $id)
|
||||||
* @method static orderBy(string $string)
|
* @method static orderBy(string $string)
|
||||||
*
|
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property string $symbol
|
* @property string $symbol
|
||||||
* @property string|null $full_name
|
* @property string|null $full_name
|
||||||
|
|
@ -35,6 +34,11 @@ public function assetPrices(): HasMany
|
||||||
return $this->hasMany(Pricing\AssetPrice::class);
|
return $this->hasMany(Pricing\AssetPrice::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function users(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function currentPrice(): ?float
|
public function currentPrice(): ?float
|
||||||
{
|
{
|
||||||
$latestPrice = $this->assetPrices()->latest('date')->first();
|
$latestPrice = $this->assetPrices()->latest('date')->first();
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @method static create(array $array)
|
* @method static create(array $array)
|
||||||
|
|
@ -12,7 +11,6 @@
|
||||||
class Milestone extends Model
|
class Milestone extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'tracker_id',
|
|
||||||
'target',
|
'target',
|
||||||
'description',
|
'description',
|
||||||
];
|
];
|
||||||
|
|
@ -20,9 +18,4 @@ class Milestone extends Model
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'target' => 'integer',
|
'target' => 'integer',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function tracker(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tracker::class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
namespace App\Models\Pricing;
|
namespace App\Models\Pricing;
|
||||||
|
|
||||||
use App\Models\Asset;
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
@ -14,7 +13,6 @@
|
||||||
* @method static where(string $string, string $string1, string $date)
|
* @method static where(string $string, string $string1, string $date)
|
||||||
* @method static updateOrCreate(string[] $array, float[] $array1)
|
* @method static updateOrCreate(string[] $array, float[] $array1)
|
||||||
* @method static orderBy(string $string, string $string1)
|
* @method static orderBy(string $string, string $string1)
|
||||||
*
|
|
||||||
* @property Carbon $date
|
* @property Carbon $date
|
||||||
* @property float $price
|
* @property float $price
|
||||||
*/
|
*/
|
||||||
|
|
@ -35,13 +33,13 @@ class AssetPrice extends Model
|
||||||
|
|
||||||
public function asset(): BelongsTo
|
public function asset(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Asset::class);
|
return $this->belongsTo(\App\Models\Asset::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function current(?int $assetId = null): ?float
|
public static function current(int $assetId = null): ?float
|
||||||
{
|
{
|
||||||
$query = static::latest('date');
|
$query = static::latest('date');
|
||||||
|
|
||||||
if ($assetId) {
|
if ($assetId) {
|
||||||
$query->where('asset_id', $assetId);
|
$query->where('asset_id', $assetId);
|
||||||
}
|
}
|
||||||
|
|
@ -51,11 +49,11 @@ public static function current(?int $assetId = null): ?float
|
||||||
return $latestPrice ? $latestPrice->price : null;
|
return $latestPrice ? $latestPrice->price : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function forDate(string $date, ?int $assetId = null): ?float
|
public static function forDate(string $date, int $assetId = null): ?float
|
||||||
{
|
{
|
||||||
$query = static::where('date', '<=', $date)
|
$query = static::where('date', '<=', $date)
|
||||||
->orderBy('date', 'desc');
|
->orderBy('date', 'desc');
|
||||||
|
|
||||||
if ($assetId) {
|
if ($assetId) {
|
||||||
$query->where('asset_id', $assetId);
|
$query->where('asset_id', $assetId);
|
||||||
}
|
}
|
||||||
|
|
@ -73,10 +71,10 @@ public static function updatePrice(int $assetId, string $date, float $price): se
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function history(?int $assetId = null, int $limit = 30): Collection
|
public static function history(int $assetId = null, int $limit = 30): Collection
|
||||||
{
|
{
|
||||||
$query = static::orderBy('date', 'desc')->limit($limit);
|
$query = static::orderBy('date', 'desc')->limit($limit);
|
||||||
|
|
||||||
if ($assetId) {
|
if ($assetId) {
|
||||||
$query->where('asset_id', $assetId);
|
$query->where('asset_id', $assetId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
52
app/Models/Transactions/Purchase.php
Normal file
52
app/Models/Transactions/Purchase.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Transactions;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Purchase extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'date',
|
||||||
|
'shares',
|
||||||
|
'price_per_share',
|
||||||
|
'total_cost',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'date' => 'date',
|
||||||
|
'shares' => 'decimal:6',
|
||||||
|
'price_per_share' => 'decimal:4',
|
||||||
|
'total_cost' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total shares
|
||||||
|
*/
|
||||||
|
public static function totalShares(): float
|
||||||
|
{
|
||||||
|
return static::sum('shares');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total investment
|
||||||
|
*/
|
||||||
|
public static function totalInvestment(): float
|
||||||
|
{
|
||||||
|
return static::sum('total_cost');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get average cost per share
|
||||||
|
*/
|
||||||
|
public static function averageCostPerShare(): float
|
||||||
|
{
|
||||||
|
$totalShares = static::totalShares();
|
||||||
|
$totalCost = static::totalInvestment();
|
||||||
|
|
||||||
|
return $totalShares > 0 ? $totalCost / $totalShares : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,23 +2,37 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Database\Factories\UserFactory;
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $asset_id
|
||||||
|
*/
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
|
'password',
|
||||||
|
'asset_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be hidden for serialization.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'password',
|
'password',
|
||||||
'remember_token',
|
'remember_token',
|
||||||
|
|
@ -32,33 +46,26 @@ protected function casts(): array
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function tracker(): HasOne
|
public function asset(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->hasOne(Tracker::class);
|
return $this->belongsTo(Asset::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
|
public function hasCompletedOnboarding(): bool
|
||||||
{
|
{
|
||||||
return $this->hasEntries() && $this->hasMilestones();
|
// Check if user has asset, purchases, and milestones
|
||||||
|
return $this->asset_id !== null
|
||||||
|
&& $this->hasPurchases()
|
||||||
|
&& $this->hasMilestones();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasEntries(): bool
|
public function hasPurchases(): bool
|
||||||
{
|
{
|
||||||
return (bool) $this->tracker?->entries()->exists();
|
return \App\Models\Transactions\Purchase::totalShares() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasMilestones(): bool
|
public function hasMilestones(): bool
|
||||||
{
|
{
|
||||||
return (bool) $this->tracker?->milestones()->exists();
|
return \App\Models\Milestone::count() > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Providers\AppServiceProvider;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"inertiajs/inertia-laravel": "^3.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"laravel/framework": "^13.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^2.10.1",
|
||||||
"tightenco/ziggy": "^2.4"
|
"tightenco/ziggy": "^2.4"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
"laravel/sail": "^1.43",
|
"laravel/sail": "^1.43",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.6",
|
||||||
"phpunit/phpunit": "^12.0"
|
"phpunit/phpunit": "^11.5.3"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|
|
||||||
8516
composer.lock
generated
8516
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -64,7 +62,7 @@
|
||||||
'providers' => [
|
'providers' => [
|
||||||
'users' => [
|
'users' => [
|
||||||
'driver' => 'eloquent',
|
'driver' => 'eloquent',
|
||||||
'model' => env('AUTH_MODEL', User::class),
|
'model' => env('AUTH_MODEL', App\Models\User::class),
|
||||||
],
|
],
|
||||||
|
|
||||||
// 'users' => [
|
// 'users' => [
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@
|
||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<User>
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
||||||
*/
|
*/
|
||||||
class UserFactory extends Factory
|
class UserFactory extends Factory
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ public function up(): void
|
||||||
$table->timestamp('email_verified_at')->nullable();
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
$table->string('password');
|
$table->string('password');
|
||||||
$table->foreignId('asset_id')->nullable()->constrained()->onDelete('set null');
|
$table->foreignId('asset_id')->nullable()->constrained()->onDelete('set null');
|
||||||
$table->boolean('price_tracking_enabled')->default(false);
|
|
||||||
$table->rememberToken();
|
$table->rememberToken();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('purchases', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->date('date');
|
||||||
|
$table->decimal('shares', 12, 6); // Supports fractional shares
|
||||||
|
$table->decimal('price_per_share', 8, 4); // Price in euros
|
||||||
|
$table->decimal('total_cost', 12, 2); // Total cost in euros
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('date');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('purchases');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('entries', function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('tracker_id')->constrained()->cascadeOnDelete();
|
|
||||||
$table->date('date');
|
|
||||||
$table->decimal('quantity', 12, 6);
|
|
||||||
$table->decimal('unit_price', 12, 4)->nullable();
|
|
||||||
$table->decimal('total_cost', 12, 2)->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index(['tracker_id', 'date']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('entries');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
CREATE DATABASE IF NOT EXISTS `testing` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
GRANT ALL PRIVILEGES ON `testing`.* TO 'incr_user'@'%';
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM docker.io/library/php:8.3-fpm
|
FROM docker.io/library/php:8.2-fpm
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
|
|
@ -9,21 +9,39 @@ RUN apt-get update && apt-get install -y \
|
||||||
libxml2-dev \
|
libxml2-dev \
|
||||||
zip \
|
zip \
|
||||||
unzip \
|
unzip \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
default-mysql-client \
|
default-mysql-client \
|
||||||
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
|
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
|
||||||
|
|
||||||
# Install Node.js 20.x via nodesource
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|
||||||
&& apt-get install -y nodejs
|
|
||||||
|
|
||||||
# Install Composer
|
# Install Composer
|
||||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /var/www/html
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
# Install Node.js 20.x (for better compatibility)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs
|
||||||
|
|
||||||
|
# Copy composer files and install PHP dependencies
|
||||||
|
COPY composer.json composer.lock ./
|
||||||
|
RUN composer install --no-dev --optimize-autoloader --no-scripts
|
||||||
|
|
||||||
|
# Copy package.json and install Node dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
RUN chown -R www-data:www-data /var/www/html \
|
||||||
|
&& chmod -R 755 /var/www/html/storage \
|
||||||
|
&& chmod -R 755 /var/www/html/bootstrap/cache
|
||||||
|
|
||||||
# Copy and set up container start script
|
# Copy and set up container start script
|
||||||
COPY docker/dev/container-start.sh /usr/local/bin/container-start.sh
|
COPY docker/dev/podman/container-start.sh /usr/local/bin/container-start.sh
|
||||||
RUN chmod +x /usr/local/bin/container-start.sh
|
RUN chmod +x /usr/local/bin/container-start.sh
|
||||||
|
|
||||||
EXPOSE 8000 5173
|
EXPOSE 8000 5173
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
|
||||||
|
|
||||||
# Create .env file if it doesn't exist
|
# Create .env file if it doesn't exist
|
||||||
if [ ! -f /var/www/html/.env ]; then
|
if [ ! -f /var/www/html/.env ]; then
|
||||||
|
|
@ -7,19 +6,15 @@ if [ ! -f /var/www/html/.env ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fix database name to match compose file
|
# Fix database name to match compose file
|
||||||
sed -i 's|^DB_DATABASE=.*|DB_DATABASE=incr_dev|' /var/www/html/.env
|
sed -i 's/DB_DATABASE=incr$/DB_DATABASE=incr_dev/' /var/www/html/.env
|
||||||
|
|
||||||
# Generate app key if not set or empty
|
# Generate app key if not set or empty
|
||||||
if ! grep -q "APP_KEY=base64:" /var/www/html/.env; then
|
if ! grep -q "APP_KEY=base64:" /var/www/html/.env; then
|
||||||
# Generate a new key and set it directly
|
# Generate a new key and set it directly
|
||||||
NEW_KEY=$(php -r "echo 'base64:' . base64_encode(random_bytes(32));")
|
NEW_KEY=$(php -r "echo 'base64:' . base64_encode(random_bytes(32));")
|
||||||
sed -i "s|^APP_KEY=.*|APP_KEY=$NEW_KEY|" /var/www/html/.env
|
sed -i "s/APP_KEY=/APP_KEY=$NEW_KEY/" /var/www/html/.env
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Install dependencies if needed
|
|
||||||
[ ! -f vendor/autoload.php ] && composer install --no-interaction
|
|
||||||
[ ! -d node_modules/.bin ] && npm install
|
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations
|
||||||
php artisan migrate --force
|
php artisan migrate --force
|
||||||
|
|
||||||
|
|
@ -1,16 +1,28 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../../..
|
||||||
dockerfile: docker/dev/Dockerfile
|
dockerfile: docker/dev/podman/Dockerfile
|
||||||
container_name: incr-dev-app
|
container_name: incr-dev-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /var/www/html
|
working_dir: /var/www/html
|
||||||
environment:
|
environment:
|
||||||
|
- APP_ENV=local
|
||||||
|
- APP_DEBUG=true
|
||||||
|
- APP_KEY=base64:YOUR_APP_KEY_HERE
|
||||||
|
- DB_CONNECTION=mysql
|
||||||
- DB_HOST=db
|
- DB_HOST=db
|
||||||
|
- DB_PORT=3306
|
||||||
|
- DB_DATABASE=incr_dev
|
||||||
|
- DB_USERNAME=incr_user
|
||||||
|
- DB_PASSWORD=incr_password
|
||||||
|
- VITE_PORT=5173
|
||||||
volumes:
|
volumes:
|
||||||
- ../../:/var/www/html:Z
|
- ../../../:/var/www/html:Z
|
||||||
- app_node_modules:/var/www/html/node_modules
|
- /var/www/html/node_modules
|
||||||
|
- /var/www/html/vendor
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
|
|
@ -31,7 +43,6 @@ services:
|
||||||
- MYSQL_ROOT_PASSWORD=root_password
|
- MYSQL_ROOT_PASSWORD=root_password
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/mysql
|
- db_data:/var/lib/mysql
|
||||||
- ./mysql-init:/docker-entrypoint-initdb.d:ro
|
|
||||||
ports:
|
ports:
|
||||||
- "3307:3306"
|
- "3307:3306"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -43,12 +54,19 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- incr-dev-network
|
- incr-dev-network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: docker.io/library/redis:7-alpine
|
||||||
|
container_name: incr-dev-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
networks:
|
||||||
|
- incr-dev-network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
incr-dev-network:
|
incr-dev-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
driver: local
|
driver: local
|
||||||
app_node_modules:
|
|
||||||
driver: local
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
# Podman aliases for Laravel Sail compatibility
|
# Podman aliases for Laravel Sail compatibility
|
||||||
# Source this file to use Sail commands with Podman
|
# Source this file to use Sail commands with Podman
|
||||||
# Usage: source docker/dev/podman-sail-alias.sh
|
# Usage: source docker/dev/podman/podman-sail-alias.sh
|
||||||
|
|
||||||
# Create docker alias pointing to podman
|
# Create docker alias pointing to podman
|
||||||
alias docker='podman'
|
alias docker='podman'
|
||||||
|
|
@ -12,10 +12,10 @@ alias docker-compose='podman-compose'
|
||||||
|
|
||||||
# Sail wrapper function that uses podman-compose
|
# Sail wrapper function that uses podman-compose
|
||||||
sail() {
|
sail() {
|
||||||
if [[ -f docker/dev/docker-compose.yml ]]; then
|
if [[ -f docker/dev/podman/docker-compose.yml ]]; then
|
||||||
podman-compose -f docker/dev/docker-compose.yml "$@"
|
podman-compose -f docker/dev/podman/docker-compose.yml "$@"
|
||||||
else
|
else
|
||||||
echo "❌ Podman compose file not found at docker/dev/docker-compose.yml"
|
echo "❌ Podman compose file not found at docker/dev/podman/docker-compose.yml"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
@ -22,7 +22,7 @@ fi
|
||||||
|
|
||||||
# Start services
|
# Start services
|
||||||
echo "🔧 Starting services..."
|
echo "🔧 Starting services..."
|
||||||
podman-compose -f docker/dev/docker-compose.yml up -d
|
podman-compose -f docker/dev/podman/docker-compose.yml up -d
|
||||||
|
|
||||||
# Wait for database to be ready
|
# Wait for database to be ready
|
||||||
echo "⏳ Waiting for database to be ready..."
|
echo "⏳ Waiting for database to be ready..."
|
||||||
|
|
@ -48,5 +48,5 @@ echo "🌐 Application: http://localhost:8000"
|
||||||
echo "🔥 Vite dev server: http://localhost:5173"
|
echo "🔥 Vite dev server: http://localhost:5173"
|
||||||
echo "💾 Database: localhost:3307"
|
echo "💾 Database: localhost:3307"
|
||||||
echo ""
|
echo ""
|
||||||
echo "To stop: podman-compose -f docker/dev/docker-compose.yml down"
|
echo "To stop: podman-compose -f docker/dev/podman/docker-compose.yml down"
|
||||||
echo "To view logs: podman-compose -f docker/dev/docker-compose.yml logs -f"
|
echo "To view logs: podman-compose -f docker/dev/podman/docker-compose.yml logs -f"
|
||||||
|
|
@ -7,7 +7,7 @@ WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install Node dependencies
|
# Install Node dependencies
|
||||||
RUN npm ci
|
RUN npm ci --only=production
|
||||||
|
|
||||||
# Copy frontend source
|
# Copy frontend source
|
||||||
COPY resources/ resources/
|
COPY resources/ resources/
|
||||||
|
|
@ -21,7 +21,7 @@ COPY eslint.config.js ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# PHP runtime stage
|
# PHP runtime stage
|
||||||
FROM php:8.3-fpm-alpine
|
FROM php:8.2-fpm-alpine
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
|
|
@ -61,9 +61,9 @@ RUN composer install --no-dev --optimize-autoloader --no-interaction
|
||||||
COPY --from=frontend-builder /app/public/build/ ./public/build/
|
COPY --from=frontend-builder /app/public/build/ ./public/build/
|
||||||
|
|
||||||
# Copy nginx and supervisor configurations
|
# Copy nginx and supervisor configurations
|
||||||
COPY docker/production/nginx.conf /etc/nginx/http.d/default.conf
|
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
|
||||||
COPY docker/production/supervisord.conf /etc/supervisord.conf
|
COPY docker/supervisord.conf /etc/supervisord.conf
|
||||||
COPY docker/production/start-app.sh /usr/local/bin/start-app
|
COPY docker/start-app.sh /usr/local/bin/start-app
|
||||||
|
|
||||||
# Set proper permissions
|
# Set proper permissions
|
||||||
RUN chown -R www-data:www-data storage bootstrap/cache public/build \
|
RUN chown -R www-data:www-data storage bootstrap/cache public/build \
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,6 @@ server {
|
||||||
root /var/www/html/public;
|
root /var/www/html/public;
|
||||||
index index.php index.html;
|
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 / {
|
location / {
|
||||||
try_files $uri $uri/ /index.php?$query_string;
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,14 @@ fi
|
||||||
|
|
||||||
# Wait for database to be ready
|
# Wait for database to be ready
|
||||||
echo "Waiting for database..."
|
echo "Waiting for database..."
|
||||||
until mysql -h"${DB_HOST:-db}" -u"${DB_USERNAME:-incr_user}" -p"${DB_PASSWORD}" -e "SELECT 1" >/dev/null 2>&1; do
|
until php artisan tinker --execute="DB::connection()->getPdo();" 2>/dev/null; do
|
||||||
echo "Database not ready, waiting..."
|
echo "Database not ready, waiting..."
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
echo "Database is ready!"
|
echo "Database is ready!"
|
||||||
|
|
||||||
# Generate app key only if not already set
|
# Generate app key if not set
|
||||||
if ! grep -q "APP_KEY=base64:" /var/www/html/.env 2>/dev/null; then
|
php artisan key:generate --force
|
||||||
php artisan key:generate --force
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Laravel optimizations
|
# Laravel optimizations
|
||||||
php artisan config:cache
|
php artisan config:cache
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[supervisord]
|
[supervisord]
|
||||||
nodaemon=true
|
nodaemon=true
|
||||||
user=www-data
|
user=root
|
||||||
|
|
||||||
[program:nginx]
|
[program:nginx]
|
||||||
command=nginx -g "daemon off;"
|
command=nginx -g "daemon off;"
|
||||||
|
|
|
||||||
4875
package-lock.json
generated
4875
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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@types/node": "^22.13.5",
|
"@types/node": "^22.13.5",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-react": "^7.37.3",
|
"eslint-plugin-react": "^7.37.3",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@inertiajs/react": "^3.0.3",
|
"@inertiajs/react": "^2.0.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.3",
|
"@radix-ui/react-avatar": "^1.1.3",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-collapsible": "^1.1.3",
|
"@radix-ui/react-collapsible": "^1.1.3",
|
||||||
|
|
@ -41,20 +41,20 @@
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"@types/react": "^19.0.3",
|
"@types/react": "^19.0.3",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"laravel-vite-plugin": "^3.1.0",
|
"laravel-vite-plugin": "^1.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^0.475.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"tailwind-merge": "^3.0.1",
|
"tailwind-merge": "^3.0.1",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^8.0.10"
|
"vite": "^6.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
||||||
|
|
|
||||||
20
phpunit.xml
20
phpunit.xml
|
|
@ -18,15 +18,15 @@
|
||||||
</include>
|
</include>
|
||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing" force="true"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="CACHE_STORE" value="array" force="true"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
<env name="DB_DATABASE" value="testing" force="true"/>
|
<env name="DB_DATABASE" value="testing"/>
|
||||||
<env name="MAIL_MAILER" value="array" force="true"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<env name="PULSE_ENABLED" value="false" force="true"/>
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
<env name="SESSION_DRIVER" value="array" force="true"/>
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
<env name="TELESCOPE_ENABLED" value="false" force="true"/>
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ interface AssetSetupFormProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssetSetupForm({ onSuccess, onCancel }: AssetSetupFormProps) {
|
export default function AssetSetupForm({ onSuccess, onCancel }: AssetSetupFormProps) {
|
||||||
const { data, setData, patch, processing, errors } = useForm<AssetFormData>({
|
const { data, setData, post, processing, errors } = useForm<AssetFormData>({
|
||||||
symbol: '',
|
symbol: '',
|
||||||
full_name: '',
|
full_name: '',
|
||||||
});
|
});
|
||||||
|
|
@ -28,13 +28,13 @@ export default function AssetSetupForm({ onSuccess, onCancel }: AssetSetupFormPr
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCurrentAsset = async () => {
|
const fetchCurrentAsset = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/tracker');
|
const response = await fetch('/assets/current');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const { tracker } = await response.json();
|
const assetData = await response.json();
|
||||||
if (tracker?.asset) {
|
if (assetData.asset) {
|
||||||
setData({
|
setData({
|
||||||
symbol: tracker.asset.symbol || '',
|
symbol: assetData.asset.symbol || '',
|
||||||
full_name: tracker.asset.full_name || '',
|
full_name: assetData.asset.full_name || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +57,7 @@ export default function AssetSetupForm({ onSuccess, onCancel }: AssetSetupFormPr
|
||||||
const submit: FormEventHandler = (e) => {
|
const submit: FormEventHandler = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
patch(route('tracker.update'), {
|
post(route('assets.set-current'), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
if (onSuccess) onSuccess();
|
if (onSuccess) onSuccess();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,65 @@
|
||||||
import AddEntryForm from '@/components/Transactions/AddEntryForm';
|
|
||||||
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
||||||
|
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
|
||||||
|
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import ComponentTitle from '@/components/ui/ComponentTitle';
|
||||||
type FormType = 'purchase' | 'milestone';
|
|
||||||
|
|
||||||
interface InlineFormProps {
|
interface InlineFormProps {
|
||||||
type: FormType | null;
|
type: 'purchase' | 'milestone' | 'price' | null;
|
||||||
unit?: string;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSuccess?: (type: FormType) => void;
|
onPurchaseSuccess?: () => void;
|
||||||
|
onMilestoneSuccess?: () => void;
|
||||||
|
onPriceSuccess?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InlineForm({
|
export default function InlineForm({
|
||||||
type,
|
type,
|
||||||
unit = 'units',
|
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onPurchaseSuccess,
|
||||||
className,
|
onMilestoneSuccess,
|
||||||
|
onPriceSuccess,
|
||||||
|
className
|
||||||
}: InlineFormProps) {
|
}: InlineFormProps) {
|
||||||
if (!type) return null;
|
if (!type) return null;
|
||||||
|
|
||||||
const handleSuccess = () => {
|
const title = type === 'purchase' ? 'ADD PURCHASE' : type === 'milestone' ? 'ADD MILESTONE' : 'UPDATE PRICE';
|
||||||
if (onSuccess) onSuccess(type);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-black p-8',
|
"bg-black p-8",
|
||||||
'transition-all duration-300',
|
"transition-all duration-300",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* Header */}
|
||||||
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
|
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
|
||||||
|
|
||||||
|
{/* Form Content */}
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
{type === 'purchase' ? (
|
{type === 'purchase' ? (
|
||||||
<AddEntryForm
|
<AddPurchaseForm
|
||||||
unit={unit}
|
onSuccess={() => {
|
||||||
onSuccess={handleSuccess}
|
if (onPurchaseSuccess) onPurchaseSuccess();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
onCancel={onClose}
|
||||||
|
/>
|
||||||
|
) : type === 'milestone' ? (
|
||||||
|
<AddMilestoneForm
|
||||||
|
onSuccess={() => {
|
||||||
|
if (onMilestoneSuccess) onMilestoneSuccess();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AddMilestoneForm
|
<UpdatePriceForm
|
||||||
onSuccess={handleSuccess}
|
onSuccess={() => {
|
||||||
|
if (onPriceSuccess) onPriceSuccess();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface LedDisplayProps {
|
interface LedDisplayProps {
|
||||||
value: number;
|
value: number;
|
||||||
unit?: string;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
animate?: boolean;
|
animate?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
|
@ -11,7 +10,6 @@ interface LedDisplayProps {
|
||||||
|
|
||||||
export default function LedDisplay({
|
export default function LedDisplay({
|
||||||
value,
|
value,
|
||||||
unit,
|
|
||||||
className,
|
className,
|
||||||
onClick
|
onClick
|
||||||
}: LedDisplayProps) {
|
}: LedDisplayProps) {
|
||||||
|
|
@ -57,11 +55,6 @@ export default function LedDisplay({
|
||||||
{formattedValue}
|
{formattedValue}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{unit && (
|
|
||||||
<div className="text-red-500/50 font-mono text-sm uppercase tracking-widest mt-2">
|
|
||||||
{unit}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,34 @@
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Milestone } from '@/types/domain';
|
|
||||||
|
interface Milestone {
|
||||||
|
target: number;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProgressBarProps {
|
interface ProgressBarProps {
|
||||||
currentQuantity: number;
|
currentShares: number;
|
||||||
milestones: Milestone[];
|
milestones: Milestone[];
|
||||||
selectedMilestoneIndex?: number;
|
selectedMilestoneIndex?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressBar({
|
export default function ProgressBar({
|
||||||
currentQuantity,
|
currentShares,
|
||||||
milestones,
|
milestones,
|
||||||
selectedMilestoneIndex = 0,
|
selectedMilestoneIndex = 0,
|
||||||
className,
|
className,
|
||||||
onClick
|
onClick
|
||||||
}: ProgressBarProps) {
|
}: ProgressBarProps) {
|
||||||
const selectedMilestone = milestones.length > 0 && selectedMilestoneIndex < milestones.length
|
// Get the selected milestone for progress calculation
|
||||||
? milestones[selectedMilestoneIndex]
|
const selectedMilestone = milestones.length > 0 && selectedMilestoneIndex < milestones.length
|
||||||
|
? milestones[selectedMilestoneIndex]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const progressPercentage = selectedMilestone
|
// Calculate progress percentage
|
||||||
? Math.min((currentQuantity / selectedMilestone.target) * 100, 100)
|
const progressPercentage = selectedMilestone
|
||||||
|
? Math.min((currentShares / selectedMilestone.target) * 100, 100)
|
||||||
: 0;
|
: 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -2,30 +2,41 @@ import { cn } from '@/lib/utils';
|
||||||
import { Plus, ChevronRight } from 'lucide-react';
|
import { Plus, ChevronRight } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
import ComponentTitle from '@/components/ui/ComponentTitle';
|
||||||
import type { Milestone } from '@/types/domain';
|
|
||||||
|
interface Milestone {
|
||||||
|
target: number;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface StatsBoxProps {
|
interface StatsBoxProps {
|
||||||
stats: {
|
stats: {
|
||||||
totalShares: number;
|
totalShares: number;
|
||||||
|
totalInvestment: number;
|
||||||
|
averageCostPerShare: number;
|
||||||
|
currentPrice?: number;
|
||||||
|
currentValue?: number;
|
||||||
|
profitLoss?: number;
|
||||||
|
profitLossPercentage?: number;
|
||||||
};
|
};
|
||||||
unit?: string;
|
|
||||||
milestones?: Milestone[];
|
milestones?: Milestone[];
|
||||||
selectedMilestoneIndex?: number;
|
selectedMilestoneIndex?: number;
|
||||||
onMilestoneSelect?: (index: number) => void;
|
onMilestoneSelect?: (index: number) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
onAddPurchase?: () => void;
|
onAddPurchase?: () => void;
|
||||||
onAddMilestone?: () => void;
|
onAddMilestone?: () => void;
|
||||||
|
onUpdatePrice?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatsBox({
|
export default function StatsBox({
|
||||||
stats,
|
stats,
|
||||||
unit = 'units',
|
|
||||||
milestones = [],
|
milestones = [],
|
||||||
selectedMilestoneIndex = 0,
|
selectedMilestoneIndex = 0,
|
||||||
onMilestoneSelect,
|
onMilestoneSelect,
|
||||||
className,
|
className,
|
||||||
onAddPurchase,
|
onAddPurchase,
|
||||||
onAddMilestone,
|
onAddMilestone,
|
||||||
|
onUpdatePrice
|
||||||
}: StatsBoxProps) {
|
}: StatsBoxProps) {
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -34,6 +45,22 @@ export default function StatsBox({
|
||||||
const nextIndex = (selectedMilestoneIndex + 1) % milestones.length;
|
const nextIndex = (selectedMilestoneIndex + 1) % milestones.length;
|
||||||
onMilestoneSelect(nextIndex);
|
onMilestoneSelect(nextIndex);
|
||||||
};
|
};
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrencyDetailed = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
minimumFractionDigits: 4,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -44,20 +71,28 @@ export default function StatsBox({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
|
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
|
||||||
|
{/* STATS Title and Current Price */}
|
||||||
<div className="flex justify-between items-center mb-6 relative">
|
<div className="flex justify-between items-center mb-6 relative">
|
||||||
<ComponentTitle>Stats</ComponentTitle>
|
<ComponentTitle>Stats</ComponentTitle>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 relative">
|
<div className="flex items-center space-x-2 relative">
|
||||||
|
{stats.currentPrice && (
|
||||||
|
<div className="text-red-500 text-sm font-mono tracking-wider">
|
||||||
|
VWCE: {formatCurrencyDetailed(stats.currentPrice)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Action Dropdown */}
|
{/* Action Dropdown */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
className="flex items-center justify-center px-2 py-1 rounded border border-red-500/50 text-red-500 hover:bg-red-800/40 hover:text-red-300 transition-colors text-sm"
|
className="flex items-center justify-center px-2 py-1 rounded border border-red-500/50 text-red-500 hover:bg-red-800/40 hover:text-red-300 transition-colors text-sm"
|
||||||
aria-label="Add actions"
|
aria-label="Add actions"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
{isDropdownOpen && (
|
{isDropdownOpen && (
|
||||||
<div className="absolute top-full right-0 mt-2 bg-black border-2 border-red-500/50 rounded shadow-lg min-w-40 z-10">
|
<div className="absolute top-full right-0 mt-2 bg-black border-2 border-red-500/50 rounded shadow-lg min-w-40 z-10">
|
||||||
{onAddPurchase && (
|
{onAddPurchase && (
|
||||||
|
|
@ -68,7 +103,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"
|
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
||||||
>
|
>
|
||||||
ADD ENTRY
|
ADD PURCHASE
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onAddMilestone && (
|
{onAddMilestone && (
|
||||||
|
|
@ -77,11 +112,22 @@ export default function StatsBox({
|
||||||
onAddMilestone();
|
onAddMilestone();
|
||||||
setIsDropdownOpen(false);
|
setIsDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
||||||
>
|
>
|
||||||
ADD MILESTONE
|
ADD MILESTONE
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onUpdatePrice && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onUpdatePrice();
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
||||||
|
>
|
||||||
|
UPDATE PRICE
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -99,45 +145,70 @@ export default function StatsBox({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Milestone Table */}
|
{/* Milestone Table */}
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<div className="text-red-500 underline font-bold mb-3 font-mono">MILESTONES</div>
|
<div className="text-red-500 underline font-bold mb-3 font-mono">MILESTONES</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm font-mono">
|
<table className="w-full text-sm font-mono">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left text-red-500 text-xs py-2">DESCRIPTION</th>
|
<th className="text-left text-red-500 text-xs py-2">DESCRIPTION</th>
|
||||||
<th className="text-right text-red-500 text-xs py-2">{unit.toUpperCase()}</th>
|
<th className="text-right text-red-500 text-xs py-2">SHARES</th>
|
||||||
</tr>
|
<th className="text-right text-red-500 text-xs py-2 pr-4">SWR 3%</th>
|
||||||
</thead>
|
<th className="text-right text-red-500 text-xs py-2">SWR 4%</th>
|
||||||
<tbody>
|
</tr>
|
||||||
<tr className="text-red-500 font-bold">
|
</thead>
|
||||||
<td className="py-1 pr-4">CURRENT</td>
|
<tbody>
|
||||||
<td className="text-right py-1">
|
{/* Current position row */}
|
||||||
{Math.floor(stats.totalShares).toLocaleString()}
|
<tr className="text-red-500 font-bold">
|
||||||
</td>
|
<td className="py-1 pr-4">CURRENT</td>
|
||||||
</tr>
|
<td className="text-right py-1 pr-4">
|
||||||
|
{Math.floor(stats.totalShares).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1 pr-4">
|
||||||
|
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.03) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1">
|
||||||
|
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.04) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
{milestones.map((milestone, index) => (
|
{/* Render milestones after current */}
|
||||||
|
{milestones.map((milestone, index) => {
|
||||||
|
const swr3 = stats.currentPrice ? milestone.target * stats.currentPrice * 0.03 : 0;
|
||||||
|
const swr4 = stats.currentPrice ? milestone.target * stats.currentPrice * 0.04 : 0;
|
||||||
|
|
||||||
|
const isSelectedMilestone = index === selectedMilestoneIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
index === selectedMilestoneIndex
|
isSelectedMilestone
|
||||||
? "bg-red-500 text-black"
|
? "bg-red-500 text-black"
|
||||||
: "text-red-500 font-bold"
|
: "text-red-500 font-bold"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<td className="py-1 pr-4">{milestone.description}</td>
|
<td className="py-1 pr-4">
|
||||||
<td className="text-right py-1">
|
{milestone.description}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1 pr-4">
|
||||||
{Math.floor(milestone.target).toLocaleString()}
|
{Math.floor(milestone.target).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="text-right py-1 pr-4">
|
||||||
|
{stats.currentPrice ? formatCurrency(swr3) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1">
|
||||||
|
{stats.currentPrice ? formatCurrency(swr4) : 'N/A'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
</tbody>
|
})}
|
||||||
</table>
|
</tbody>
|
||||||
</div>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import InputError from '@/components/InputError';
|
|
||||||
import { LoaderCircle } from 'lucide-react';
|
|
||||||
import { FormEventHandler, useState } from 'react';
|
|
||||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
|
||||||
|
|
||||||
interface CreateTrackerStepProps {
|
|
||||||
onSuccess: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CreateTrackerStep({ onSuccess }: CreateTrackerStepProps) {
|
|
||||||
const [label, setLabel] = useState('');
|
|
||||||
const [unit, setUnit] = useState('');
|
|
||||||
const [processing, setProcessing] = useState(false);
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const submit: FormEventHandler = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setProcessing(true);
|
|
||||||
setErrors({});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(route('tracker.store'), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
label,
|
|
||||||
unit,
|
|
||||||
price_tracking_enabled: 0,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok || response.status === 201 || response.status === 409) {
|
|
||||||
onSuccess();
|
|
||||||
} else {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.errors) {
|
|
||||||
setErrors(data.errors);
|
|
||||||
} else if (data.message) {
|
|
||||||
setErrors({ label: data.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setErrors({ label: 'Something went wrong. Please try again.' });
|
|
||||||
} finally {
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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={label}
|
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-red-400/60 mt-1 font-mono">
|
|
||||||
[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={unit}
|
|
||||||
onChange={(e) => setUnit(e.target.value)}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-red-400/60 mt-1 font-mono">
|
|
||||||
[REQUIRED] e.g. "shares", "books", "km"
|
|
||||||
</p>
|
|
||||||
<InputError message={errors.unit} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={processing || !label || !unit}
|
|
||||||
className="w-full bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
[INITIALIZE]
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import AddEntryForm from '@/components/Transactions/AddEntryForm';
|
import AssetSetupForm from '@/components/Assets/AssetSetupForm';
|
||||||
|
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
|
||||||
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
||||||
import CreateTrackerStep from '@/components/Onboarding/CreateTrackerStep';
|
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
|
||||||
|
|
||||||
interface OnboardingStep {
|
interface OnboardingStep {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -11,81 +12,121 @@ interface OnboardingStep {
|
||||||
required: boolean;
|
required: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS: OnboardingStep[] = [
|
|
||||||
{ id: 'entries', title: 'STARTING AMOUNT', description: 'Enter your starting amount', completed: false, required: true },
|
|
||||||
{ id: 'milestones', title: 'SET MILESTONES', description: 'Define your goals', completed: false, required: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface OnboardingFlowProps {
|
interface OnboardingFlowProps {
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
||||||
const [trackerCreated, setTrackerCreated] = useState(false);
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [steps, setSteps] = useState<OnboardingStep[]>([]);
|
const [steps, setSteps] = useState<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: 'milestones',
|
||||||
|
title: 'SET MILESTONES',
|
||||||
|
description: 'Define your investment goals',
|
||||||
|
completed: false,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'price',
|
||||||
|
title: 'CURRENT PRICE',
|
||||||
|
description: 'Set current asset price',
|
||||||
|
completed: false,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
// On mount: check if a tracker already exists and skip step 1 if so
|
// Check onboarding status on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/tracker')
|
checkOnboardingStatus();
|
||||||
.then(r => r.ok ? r.json() : null)
|
|
||||||
.then(data => {
|
|
||||||
if (data?.tracker) {
|
|
||||||
setTrackerCreated(true);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const checkOnboardingStatus = useCallback(async (currentSteps: OnboardingStep[]) => {
|
const checkOnboardingStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const [entriesData, milestonesData] = await Promise.all([
|
// Check asset
|
||||||
fetch('/entries/summary').then(r => r.json()),
|
const assetResponse = await fetch('/assets/current');
|
||||||
fetch('/milestones').then(r => r.json()),
|
const assetData = await assetResponse.json();
|
||||||
]);
|
const hasAsset = !!assetData.asset;
|
||||||
|
|
||||||
const hasEntries = entriesData.total_quantity > 0;
|
// Check purchases
|
||||||
|
const purchaseResponse = await fetch('/purchases/summary');
|
||||||
|
const purchaseData = await purchaseResponse.json();
|
||||||
|
const hasPurchases = purchaseData.total_shares > 0;
|
||||||
|
|
||||||
|
// Check milestones
|
||||||
|
const milestonesResponse = await fetch('/milestones');
|
||||||
|
const milestonesData = await milestonesResponse.json();
|
||||||
const hasMilestones = milestonesData.length > 0;
|
const hasMilestones = milestonesData.length > 0;
|
||||||
|
|
||||||
const freshSteps = currentSteps.map(step => ({
|
// Check current price
|
||||||
|
const priceResponse = await fetch('/pricing/current');
|
||||||
|
const priceData = await priceResponse.json();
|
||||||
|
const hasPrice = !!priceData.current_price;
|
||||||
|
|
||||||
|
setSteps(prev => prev.map(step => ({
|
||||||
...step,
|
...step,
|
||||||
completed:
|
completed:
|
||||||
(step.id === 'entries' && hasEntries) ||
|
(step.id === 'asset' && hasAsset) ||
|
||||||
(step.id === 'milestones' && hasMilestones),
|
(step.id === 'purchases' && hasPurchases) ||
|
||||||
}));
|
(step.id === 'milestones' && hasMilestones) ||
|
||||||
|
(step.id === 'price' && hasPrice)
|
||||||
|
})));
|
||||||
|
|
||||||
setSteps(freshSteps);
|
// Find first incomplete required step
|
||||||
|
const firstIncompleteStep = steps.findIndex(step =>
|
||||||
const firstIncompleteRequired = freshSteps.findIndex(s => s.required && !s.completed);
|
step.required && !step.completed
|
||||||
|
);
|
||||||
if (firstIncompleteRequired !== -1) {
|
|
||||||
setCurrentStep(firstIncompleteRequired);
|
if (firstIncompleteStep !== -1) {
|
||||||
} else if (onComplete) {
|
setCurrentStep(firstIncompleteStep);
|
||||||
onComplete();
|
} else {
|
||||||
|
// All required steps complete, check if we should call onComplete
|
||||||
|
const allRequiredComplete = steps.filter(s => s.required).every(s => s.completed);
|
||||||
|
if (allRequiredComplete && onComplete) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check onboarding status:', error);
|
console.error('Failed to check onboarding status:', error);
|
||||||
}
|
}
|
||||||
}, [onComplete]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!trackerCreated) return;
|
|
||||||
|
|
||||||
setSteps(STEPS);
|
|
||||||
setCurrentStep(0);
|
|
||||||
checkOnboardingStatus(STEPS);
|
|
||||||
}, [trackerCreated, checkOnboardingStatus]);
|
|
||||||
|
|
||||||
const handleTrackerCreated = () => {
|
|
||||||
setTrackerCreated(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStepComplete = async () => {
|
const handleStepComplete = async () => {
|
||||||
const updatedSteps = steps.map((step, index) =>
|
// Mark current step as completed
|
||||||
|
setSteps(prev => prev.map((step, index) =>
|
||||||
index === currentStep ? { ...step, completed: true } : step
|
index === currentStep ? { ...step, completed: true } : step
|
||||||
|
));
|
||||||
|
|
||||||
|
// Refresh onboarding status
|
||||||
|
await checkOnboardingStatus();
|
||||||
|
|
||||||
|
// Move to next incomplete step or complete onboarding
|
||||||
|
const nextIncompleteStep = steps.findIndex((step, index) =>
|
||||||
|
index > currentStep && step.required && !step.completed
|
||||||
);
|
);
|
||||||
setSteps(updatedSteps);
|
|
||||||
await checkOnboardingStatus(updatedSteps);
|
if (nextIncompleteStep !== -1) {
|
||||||
|
setCurrentStep(nextIncompleteStep);
|
||||||
|
} else {
|
||||||
|
// All required steps complete
|
||||||
|
const allRequiredComplete = steps.filter(s => s.required).every(s => s.completed);
|
||||||
|
if (allRequiredComplete && onComplete) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStepSelect = (stepIndex: number) => {
|
const handleStepSelect = (stepIndex: number) => {
|
||||||
|
|
@ -94,13 +135,32 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
||||||
|
|
||||||
const renderStepContent = () => {
|
const renderStepContent = () => {
|
||||||
const step = steps[currentStep];
|
const step = steps[currentStep];
|
||||||
if (!step) return null;
|
|
||||||
|
|
||||||
switch (step.id) {
|
switch (step.id) {
|
||||||
case 'entries':
|
case 'asset':
|
||||||
return <AddEntryForm onSuccess={handleStepComplete} />;
|
return (
|
||||||
|
<AssetSetupForm
|
||||||
|
onSuccess={handleStepComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'purchases':
|
||||||
|
return (
|
||||||
|
<AddPurchaseForm
|
||||||
|
onSuccess={handleStepComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case 'milestones':
|
case 'milestones':
|
||||||
return <AddMilestoneForm onSuccess={handleStepComplete} />;
|
return (
|
||||||
|
<AddMilestoneForm
|
||||||
|
onSuccess={handleStepComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'price':
|
||||||
|
return (
|
||||||
|
<UpdatePriceForm
|
||||||
|
onSuccess={handleStepComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -109,69 +169,66 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-black flex items-center justify-center p-4">
|
<div className="min-h-screen bg-black flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-4xl">
|
<div className="w-full max-w-4xl">
|
||||||
|
{/* Terminal-style border with red glow */}
|
||||||
<div className="border-2 border-red-500 bg-black shadow-[0_0_20px_rgba(239,68,68,0.3)] p-8">
|
<div className="border-2 border-red-500 bg-black shadow-[0_0_20px_rgba(239,68,68,0.3)] p-8">
|
||||||
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-red-400 font-mono text-2xl font-bold uppercase tracking-wider mb-2">
|
<h1 className="text-red-400 font-mono text-2xl font-bold uppercase tracking-wider mb-2">
|
||||||
[SYSTEM] ONBOARDING SEQUENCE
|
[SYSTEM] ONBOARDING SEQUENCE
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-red-400/60 font-mono text-sm">
|
<p className="text-red-400/60 font-mono text-sm">
|
||||||
{!trackerCreated ? 'Set up your tracker' : 'Configure your tracker'}
|
Initialize your asset tracking system
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!trackerCreated ? (
|
{/* Progress indicator */}
|
||||||
<div className="border border-red-500/30 bg-black/50 p-6">
|
<div className="mb-8">
|
||||||
<CreateTrackerStep onSuccess={handleTrackerCreated} />
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<button
|
||||||
|
key={step.id}
|
||||||
|
onClick={() => handleStepSelect(index)}
|
||||||
|
className={`flex-1 px-4 py-2 font-mono text-xs uppercase tracking-wider border border-red-500/50 transition-all ${
|
||||||
|
index === currentStep
|
||||||
|
? 'bg-red-500 text-black border-red-500'
|
||||||
|
: step.completed
|
||||||
|
? 'bg-red-950/50 text-red-300 border-red-400'
|
||||||
|
: 'bg-black text-red-400/60 hover:text-red-400 hover:border-red-400'
|
||||||
|
} ${index > 0 ? 'ml-2' : ''}`}
|
||||||
|
>
|
||||||
|
{step.completed ? '[✓]' : step.required ? '[REQ]' : '[OPT]'} {step.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<>
|
<div className="text-center">
|
||||||
<div className="mb-8">
|
<p className="text-red-400 font-mono text-sm">
|
||||||
<div className="flex items-center justify-between mb-4">
|
{steps[currentStep].description}
|
||||||
{steps.map((step, index) => (
|
</p>
|
||||||
<button
|
<p className="text-red-400/60 font-mono text-xs mt-1">
|
||||||
key={step.id}
|
STEP {currentStep + 1}/{steps.length}
|
||||||
onClick={() => handleStepSelect(index)}
|
</p>
|
||||||
className={`flex-1 px-4 py-2 font-mono text-xs uppercase tracking-wider border border-red-500/50 transition-all ${
|
</div>
|
||||||
index === currentStep
|
</div>
|
||||||
? 'bg-red-500 text-black border-red-500'
|
|
||||||
: step.completed
|
|
||||||
? 'bg-red-950/50 text-red-300 border-red-400'
|
|
||||||
: 'bg-black text-red-400/60 hover:text-red-400 hover:border-red-400'
|
|
||||||
} ${index > 0 ? 'ml-2' : ''}`}
|
|
||||||
>
|
|
||||||
{step.completed ? '[✓]' : '[REQ]'} {step.title}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
{/* Step content */}
|
||||||
<p className="text-red-400 font-mono text-sm">
|
<div className="border border-red-500/30 bg-black/50 p-6">
|
||||||
{steps[currentStep]?.description}
|
{renderStepContent()}
|
||||||
</p>
|
</div>
|
||||||
<p className="text-red-400/60 font-mono text-xs mt-1">
|
|
||||||
STEP {currentStep + 1}/{steps.length}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border border-red-500/30 bg-black/50 p-6">
|
{/* Status footer */}
|
||||||
{renderStepContent()}
|
<div className="mt-6 pt-4 border-t border-red-500/30">
|
||||||
</div>
|
<div className="flex justify-between items-center">
|
||||||
|
<p className="text-red-400/60 font-mono text-xs">
|
||||||
<div className="mt-6 pt-4 border-t border-red-500/30">
|
[STATUS] {steps.filter(s => s.completed).length}/{steps.length} STEPS COMPLETE
|
||||||
<div className="flex justify-between items-center">
|
</p>
|
||||||
<p className="text-red-400/60 font-mono text-xs">
|
<p className="text-red-400/60 font-mono text-xs">
|
||||||
[STATUS] {steps.filter(s => s.completed).length}/{steps.length} STEPS COMPLETE
|
{steps.filter(s => s.required && !s.completed).length} REQUIRED REMAINING
|
||||||
</p>
|
</p>
|
||||||
<p className="text-red-400/60 font-mono text-xs">
|
</div>
|
||||||
{steps.filter(s => !s.completed).length} REQUIRED REMAINING
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3,7 +3,6 @@ import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import InputError from '@/components/InputError';
|
import InputError from '@/components/InputError';
|
||||||
import { useForm } from '@inertiajs/react';
|
import { useForm } from '@inertiajs/react';
|
||||||
import { todayISO } from '@/lib/utils';
|
|
||||||
import { LoaderCircle } from 'lucide-react';
|
import { LoaderCircle } from 'lucide-react';
|
||||||
import { FormEventHandler } from 'react';
|
import { FormEventHandler } from 'react';
|
||||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
import ComponentTitle from '@/components/ui/ComponentTitle';
|
||||||
|
|
@ -23,7 +22,7 @@ interface UpdatePriceFormProps {
|
||||||
|
|
||||||
export default function UpdatePriceForm({ currentPrice, className, onSuccess, onCancel }: UpdatePriceFormProps) {
|
export default function UpdatePriceForm({ currentPrice, className, onSuccess, onCancel }: UpdatePriceFormProps) {
|
||||||
const { data, setData, post, processing, errors } = useForm<PriceUpdateFormData>({
|
const { data, setData, post, processing, errors } = useForm<PriceUpdateFormData>({
|
||||||
date: todayISO(), // Today's date in YYYY-MM-DD format
|
date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
|
||||||
price: currentPrice?.toString() || '100.00',
|
price: currentPrice?.toString() || '100.00',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -61,7 +60,7 @@ export default function UpdatePriceForm({ currentPrice, className, onSuccess, on
|
||||||
type="date"
|
type="date"
|
||||||
value={data.date}
|
value={data.date}
|
||||||
onChange={(e) => setData('date', e.target.value)}
|
onChange={(e) => setData('date', e.target.value)}
|
||||||
max={todayISO()}
|
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"
|
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} />
|
<InputError message={errors.date} />
|
||||||
|
|
|
||||||
53
resources/js/components/Settings/AppearanceDropdown.tsx
Normal file
53
resources/js/components/Settings/AppearanceDropdown.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
resources/js/components/Settings/AppearanceTabs.tsx
Normal file
34
resources/js/components/Settings/AppearanceTabs.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import InputError from '@/components/InputError';
|
|
||||||
import { useForm } from '@inertiajs/react';
|
|
||||||
import { todayISO } from '@/lib/utils';
|
|
||||||
import { LoaderCircle } from 'lucide-react';
|
|
||||||
import { FormEventHandler, useEffect, useState } from 'react';
|
|
||||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
|
||||||
|
|
||||||
interface EntryFormData {
|
|
||||||
date: string;
|
|
||||||
quantity: string;
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AddEntryFormProps {
|
|
||||||
unit?: string;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EntrySummary {
|
|
||||||
total_quantity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddEntryForm({ unit = 'units', onSuccess, onCancel }: AddEntryFormProps) {
|
|
||||||
const { data, setData, post, processing, errors, reset } = useForm<EntryFormData>({
|
|
||||||
date: todayISO(),
|
|
||||||
quantity: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const submit: FormEventHandler = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
post(route('entries.store'), {
|
|
||||||
onSuccess: () => {
|
|
||||||
reset();
|
|
||||||
setData('date', todayISO());
|
|
||||||
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}
|
|
||||||
</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={todayISO()}
|
|
||||||
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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
178
resources/js/components/Transactions/AddPurchaseForm.tsx
Normal file
178
resources/js/components/Transactions/AddPurchaseForm.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
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 PurchaseFormData {
|
||||||
|
date: string;
|
||||||
|
shares: string;
|
||||||
|
price_per_share: string;
|
||||||
|
total_cost: string;
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddPurchaseFormProps {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchaseSummary {
|
||||||
|
total_shares: number;
|
||||||
|
total_investment: number;
|
||||||
|
average_cost_per_share: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddPurchaseForm({ onSuccess, onCancel }: AddPurchaseFormProps) {
|
||||||
|
const { data, setData, post, processing, errors, reset } = useForm<PurchaseFormData>({
|
||||||
|
date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
|
||||||
|
shares: '',
|
||||||
|
price_per_share: '',
|
||||||
|
total_cost: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [currentHoldings, setCurrentHoldings] = useState<PurchaseSummary | null>(null);
|
||||||
|
|
||||||
|
// Load existing holdings data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCurrentHoldings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/purchases/summary');
|
||||||
|
if (response.ok) {
|
||||||
|
const summary = await response.json();
|
||||||
|
setCurrentHoldings(summary);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch current holdings:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCurrentHoldings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-calculate total cost when shares or price changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (data.shares && data.price_per_share) {
|
||||||
|
const shares = parseFloat(data.shares);
|
||||||
|
const pricePerShare = parseFloat(data.price_per_share);
|
||||||
|
|
||||||
|
if (!isNaN(shares) && !isNaN(pricePerShare)) {
|
||||||
|
const totalCost = (shares * pricePerShare).toFixed(2);
|
||||||
|
setData('total_cost', totalCost);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data.shares, data.price_per_share, setData]);
|
||||||
|
|
||||||
|
const submit: FormEventHandler = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
post(route('purchases.store'), {
|
||||||
|
onSuccess: () => {
|
||||||
|
reset();
|
||||||
|
setData('date', new Date().toISOString().split('T')[0]);
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ComponentTitle>ADD PURCHASE</ComponentTitle>
|
||||||
|
{currentHoldings && currentHoldings.total_shares > 0 && (
|
||||||
|
<p className="text-sm text-red-400/60 font-mono">
|
||||||
|
[CURRENT] {currentHoldings.total_shares.toFixed(6)} shares • €{currentHoldings.total_investment.toFixed(2)} invested
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="date" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Purchase Date</Label>
|
||||||
|
<Input
|
||||||
|
id="date"
|
||||||
|
type="date"
|
||||||
|
value={data.date}
|
||||||
|
onChange={(e) => setData('date', e.target.value)}
|
||||||
|
max={new Date().toISOString().split('T')[0]}
|
||||||
|
className="bg-black border-red-500 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="shares" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Number of Shares</Label>
|
||||||
|
<Input
|
||||||
|
id="shares"
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
min="0"
|
||||||
|
placeholder="1.234567"
|
||||||
|
value={data.shares}
|
||||||
|
onChange={(e) => setData('shares', e.target.value)}
|
||||||
|
className="bg-black border-red-500 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.shares} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="price_per_share" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Price per Share (€)</Label>
|
||||||
|
<Input
|
||||||
|
id="price_per_share"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="123.45"
|
||||||
|
value={data.price_per_share}
|
||||||
|
onChange={(e) => setData('price_per_share', e.target.value)}
|
||||||
|
className="bg-black border-red-500 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.price_per_share} />
|
||||||
|
</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] shares × 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
resources/js/layouts/settings/layout.tsx
Normal file
68
resources/js/layouts/settings/layout.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,5 +4,3 @@ import { twMerge } from 'tailwind-merge';
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const todayISO = (): string => new Date().toISOString().split('T')[0];
|
|
||||||
|
|
|
||||||
60
resources/js/pages/auth/confirm-password.tsx
Normal file
60
resources/js/pages/auth/confirm-password.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
resources/js/pages/auth/forgot-password.tsx
Normal file
63
resources/js/pages/auth/forgot-password.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
resources/js/pages/auth/login.tsx
Normal file
110
resources/js/pages/auth/login.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
resources/js/pages/auth/register.tsx
Normal file
119
resources/js/pages/auth/register.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
resources/js/pages/auth/reset-password.tsx
Normal file
98
resources/js/pages/auth/reset-password.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
resources/js/pages/auth/verify-email.tsx
Normal file
41
resources/js/pages/auth/verify-email.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// 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,54 +5,77 @@ import StatsBox from '@/components/Display/StatsBox';
|
||||||
import OnboardingFlow from '@/components/Onboarding/OnboardingFlow';
|
import OnboardingFlow from '@/components/Onboarding/OnboardingFlow';
|
||||||
import TerminalSpinner from '@/components/ui/TerminalSpinner';
|
import TerminalSpinner from '@/components/ui/TerminalSpinner';
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { Milestone, Tracker } from '@/types/domain';
|
|
||||||
|
|
||||||
interface EntrySummary {
|
interface PurchaseSummary {
|
||||||
total_shares: number;
|
total_shares: number;
|
||||||
|
total_investment: number;
|
||||||
|
average_cost_per_share: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CurrentPrice {
|
||||||
|
current_price: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Milestone {
|
||||||
|
target: number;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [totalShares, setTotalShares] = useState(0);
|
const [purchaseData, setPurchaseData] = useState<PurchaseSummary>({
|
||||||
|
total_shares: 0,
|
||||||
|
total_investment: 0,
|
||||||
|
average_cost_per_share: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [priceData, setPriceData] = useState<CurrentPrice>({
|
||||||
|
current_price: null,
|
||||||
|
});
|
||||||
|
|
||||||
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||||
const [selectedMilestoneIndex, setSelectedMilestoneIndex] = useState(0);
|
const [selectedMilestoneIndex, setSelectedMilestoneIndex] = useState(0);
|
||||||
const [showProgressBar, setShowProgressBar] = useState(false);
|
const [showProgressBar, setShowProgressBar] = useState(false);
|
||||||
const [showStatsBox, setShowStatsBox] = useState(false);
|
const [showStatsBox, setShowStatsBox] = useState(false);
|
||||||
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | null>(null);
|
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | 'price' | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||||
const [tracker, setTracker] = useState<Tracker | null>(null);
|
const [currentAsset, setCurrentAsset] = useState<any>(null);
|
||||||
|
|
||||||
|
// Fetch purchase summary, current price, milestones, and check onboarding
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [entriesResponse, milestonesResponse, trackerResponse] = await Promise.all([
|
const [purchaseResponse, priceResponse, milestonesResponse, assetResponse] = await Promise.all([
|
||||||
fetch('/entries/summary'),
|
fetch('/purchases/summary'),
|
||||||
|
fetch('/pricing/current'),
|
||||||
fetch('/milestones'),
|
fetch('/milestones'),
|
||||||
fetch('/tracker'),
|
fetch('/assets/current'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let totalQuantity = 0;
|
if (purchaseResponse.ok) {
|
||||||
let milestonesCount = 0;
|
const purchases = await purchaseResponse.json();
|
||||||
|
setPurchaseData(purchases);
|
||||||
|
}
|
||||||
|
|
||||||
if (entriesResponse.ok) {
|
if (priceResponse.ok) {
|
||||||
const entries = await entriesResponse.json();
|
const price = await priceResponse.json();
|
||||||
setTotalShares(entries.total_quantity);
|
setPriceData(price);
|
||||||
totalQuantity = entries.total_quantity;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (milestonesResponse.ok) {
|
if (milestonesResponse.ok) {
|
||||||
const milestonesData = await milestonesResponse.json();
|
const milestonesData = await milestonesResponse.json();
|
||||||
setMilestones(milestonesData);
|
setMilestones(milestonesData);
|
||||||
milestonesCount = milestonesData.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trackerResponse.ok) {
|
if (assetResponse.ok) {
|
||||||
const { tracker: trackerData } = await trackerResponse.json();
|
const assetData = await assetResponse.json();
|
||||||
setTracker(trackerData ?? null);
|
setCurrentAsset(assetData.asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
setNeedsOnboarding(totalQuantity === 0 || milestonesCount === 0);
|
// Check if onboarding is needed after all data is loaded
|
||||||
|
await checkOnboardingStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch data:', error);
|
console.error('Failed to fetch data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -63,24 +86,54 @@ export default function Dashboard() {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePurchaseSuccess = async () => {
|
// Check if user needs onboarding
|
||||||
|
const checkOnboardingStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const entriesResponse = await fetch('/entries/summary');
|
const [assetResponse, purchaseResponse, milestonesResponse] = await Promise.all([
|
||||||
if (entriesResponse.ok) {
|
fetch('/assets/current'),
|
||||||
const entries = await entriesResponse.json();
|
fetch('/purchases/summary'),
|
||||||
setTotalShares(entries.total_quantity);
|
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 = !hasAsset || !hasPurchases || !hasMilestones;
|
||||||
|
setNeedsOnboarding(needsOnboarding);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh entry data:', 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
|
||||||
|
const handlePurchaseSuccess = async () => {
|
||||||
|
try {
|
||||||
|
const purchaseResponse = await fetch('/purchases/summary');
|
||||||
|
if (purchaseResponse.ok) {
|
||||||
|
const purchases = await purchaseResponse.json();
|
||||||
|
setPurchaseData(purchases);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh purchase data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refresh milestones after successful creation
|
||||||
const handleMilestoneSuccess = async () => {
|
const handleMilestoneSuccess = async () => {
|
||||||
try {
|
try {
|
||||||
const milestonesResponse = await fetch('/milestones');
|
const milestonesResponse = await fetch('/milestones');
|
||||||
if (milestonesResponse.ok) {
|
if (milestonesResponse.ok) {
|
||||||
const milestonesData = await milestonesResponse.json();
|
const milestonesData = await milestonesResponse.json();
|
||||||
setMilestones(milestonesData);
|
setMilestones(milestonesData);
|
||||||
|
// Reset to first milestone when milestones change
|
||||||
setSelectedMilestoneIndex(0);
|
setSelectedMilestoneIndex(0);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -88,39 +141,47 @@ export default function Dashboard() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle milestone selection
|
||||||
const handleMilestoneSelect = (index: number) => {
|
const handleMilestoneSelect = (index: number) => {
|
||||||
setSelectedMilestoneIndex(index);
|
setSelectedMilestoneIndex(index);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOnboardingComplete = useCallback(async () => {
|
// Refresh price data after successful update
|
||||||
const [entriesResponse, milestonesResponse, trackerResponse] = await Promise.all([
|
const handlePriceSuccess = async () => {
|
||||||
fetch('/entries/summary'),
|
try {
|
||||||
fetch('/milestones'),
|
const priceResponse = await fetch('/pricing/current');
|
||||||
fetch('/tracker'),
|
if (priceResponse.ok) {
|
||||||
]);
|
const price = await priceResponse.json();
|
||||||
|
setPriceData(price);
|
||||||
let totalQuantity = 0;
|
}
|
||||||
let milestonesCount = 0;
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh price data:', error);
|
||||||
if (entriesResponse.ok) {
|
|
||||||
const entries = await entriesResponse.json();
|
|
||||||
setTotalShares(entries.total_quantity);
|
|
||||||
totalQuantity = entries.total_quantity;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (milestonesResponse.ok) {
|
|
||||||
const milestonesData = await milestonesResponse.json();
|
|
||||||
setMilestones(milestonesData);
|
|
||||||
milestonesCount = milestonesData.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trackerResponse.ok) {
|
// Calculate portfolio stats
|
||||||
const { tracker: trackerData } = await trackerResponse.json();
|
const currentValue = priceData.current_price
|
||||||
setTracker(trackerData ?? null);
|
? purchaseData.total_shares * priceData.current_price
|
||||||
}
|
: undefined;
|
||||||
|
|
||||||
setNeedsOnboarding(totalQuantity === 0 || milestonesCount === 0);
|
const profitLoss = currentValue
|
||||||
}, []);
|
? currentValue - purchaseData.total_investment
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const profitLossPercentage = profitLoss && purchaseData.total_investment > 0
|
||||||
|
? (profitLoss / purchaseData.total_investment) * 100
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const statsData = {
|
||||||
|
totalShares: purchaseData.total_shares,
|
||||||
|
totalInvestment: purchaseData.total_investment,
|
||||||
|
averageCostPerShare: purchaseData.average_cost_per_share,
|
||||||
|
currentPrice: priceData.current_price || undefined,
|
||||||
|
currentValue,
|
||||||
|
profitLoss,
|
||||||
|
profitLossPercentage,
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -131,19 +192,56 @@ export default function Dashboard() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle handlers with cascading behavior
|
||||||
const handleLedClick = () => {
|
const handleLedClick = () => {
|
||||||
const newShowProgressBar = !showProgressBar;
|
const newShowProgressBar = !showProgressBar;
|
||||||
setShowProgressBar(newShowProgressBar);
|
setShowProgressBar(newShowProgressBar);
|
||||||
if (!newShowProgressBar) {
|
if (!newShowProgressBar) {
|
||||||
|
// If hiding progress bar, also hide stats box
|
||||||
setShowStatsBox(false);
|
setShowStatsBox(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProgressClick = () => {
|
const handleProgressClick = () => {
|
||||||
setShowStatsBox(!showStatsBox);
|
setShowStatsBox(!showStatsBox);
|
||||||
setActiveForm(null);
|
setActiveForm(null)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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'),
|
||||||
|
fetch('/pricing/current'),
|
||||||
|
fetch('/milestones'),
|
||||||
|
fetch('/assets/current'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (purchaseResponse.ok) {
|
||||||
|
const purchases = await purchaseResponse.json();
|
||||||
|
setPurchaseData(purchases);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priceResponse.ok) {
|
||||||
|
const price = await priceResponse.json();
|
||||||
|
setPriceData(price);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (milestonesResponse.ok) {
|
||||||
|
const milestonesData = await milestonesResponse.json();
|
||||||
|
setMilestones(milestonesData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetResponse.ok) {
|
||||||
|
const assetData = await assetResponse.json();
|
||||||
|
setCurrentAsset(assetData.asset);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show onboarding if needed
|
||||||
if (needsOnboarding) {
|
if (needsOnboarding) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -155,48 +253,50 @@ export default function Dashboard() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="incr" />
|
<Head title="VWCE Tracker" />
|
||||||
|
|
||||||
|
{/* Stacked Layout */}
|
||||||
<div className="min-h-screen bg-black">
|
<div className="min-h-screen bg-black">
|
||||||
<div className="w-full max-w-4xl mx-auto px-4">
|
<div className="w-full max-w-4xl mx-auto px-4">
|
||||||
|
{/* Box 1: LED Number Display - Fixed position from top */}
|
||||||
<div className="pt-32">
|
<div className="pt-32">
|
||||||
<LedDisplay
|
<LedDisplay
|
||||||
value={totalShares}
|
value={purchaseData.total_shares}
|
||||||
unit={tracker?.unit}
|
|
||||||
onClick={handleLedClick}
|
onClick={handleLedClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Box 2: Progress Bar (toggleable) */}
|
||||||
<div style={{ display: showProgressBar ? 'block' : 'none' }}>
|
<div style={{ display: showProgressBar ? 'block' : 'none' }}>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
currentQuantity={totalShares}
|
currentShares={purchaseData.total_shares}
|
||||||
milestones={milestones}
|
milestones={milestones}
|
||||||
selectedMilestoneIndex={selectedMilestoneIndex}
|
selectedMilestoneIndex={selectedMilestoneIndex}
|
||||||
onClick={handleProgressClick}
|
onClick={handleProgressClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Box 3: Stats Box (toggleable) */}
|
||||||
<div style={{ display: showStatsBox ? 'block' : 'none' }}>
|
<div style={{ display: showStatsBox ? 'block' : 'none' }}>
|
||||||
<StatsBox
|
<StatsBox
|
||||||
stats={{ totalShares }}
|
stats={statsData}
|
||||||
unit={tracker?.unit}
|
|
||||||
milestones={milestones}
|
milestones={milestones}
|
||||||
selectedMilestoneIndex={selectedMilestoneIndex}
|
selectedMilestoneIndex={selectedMilestoneIndex}
|
||||||
onMilestoneSelect={handleMilestoneSelect}
|
onMilestoneSelect={handleMilestoneSelect}
|
||||||
onAddPurchase={() => setActiveForm('purchase')}
|
onAddPurchase={() => setActiveForm('purchase')}
|
||||||
onAddMilestone={() => setActiveForm('milestone')}
|
onAddMilestone={() => setActiveForm('milestone')}
|
||||||
|
onUpdatePrice={() => setActiveForm('price')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Box 4: Forms (only when active form is set) */}
|
||||||
<div style={{ display: activeForm && showProgressBar && showStatsBox ? 'block' : 'none' }}>
|
<div style={{ display: activeForm && showProgressBar && showStatsBox ? 'block' : 'none' }}>
|
||||||
<InlineForm
|
<InlineForm
|
||||||
type={activeForm}
|
type={activeForm}
|
||||||
unit={tracker?.unit}
|
|
||||||
onClose={() => setActiveForm(null)}
|
onClose={() => setActiveForm(null)}
|
||||||
onSuccess={(type) => {
|
onPurchaseSuccess={handlePurchaseSuccess}
|
||||||
if (type === 'purchase') handlePurchaseSuccess();
|
onMilestoneSuccess={handleMilestoneSuccess}
|
||||||
else if (type === 'milestone') handleMilestoneSuccess();
|
onPriceSuccess={handlePriceSuccess}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
30
resources/js/pages/settings/appearance.tsx
Normal file
30
resources/js/pages/settings/appearance.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
resources/js/pages/settings/password.tsx
Normal file
128
resources/js/pages/settings/password.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
resources/js/pages/settings/profile.tsx
Normal file
127
resources/js/pages/settings/profile.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
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,20 +0,0 @@
|
||||||
export interface Milestone {
|
|
||||||
id?: number;
|
|
||||||
target: number;
|
|
||||||
description: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrackerAsset {
|
|
||||||
id: number;
|
|
||||||
symbol: string;
|
|
||||||
full_name: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Tracker {
|
|
||||||
id: number;
|
|
||||||
label: string;
|
|
||||||
unit: string;
|
|
||||||
price_tracking_enabled: boolean;
|
|
||||||
asset: TrackerAsset | null;
|
|
||||||
}
|
|
||||||
|
|
@ -31,7 +31,6 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
||||||
|
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
|
@ -44,7 +43,7 @@
|
||||||
|
|
||||||
@routes
|
@routes
|
||||||
@viteReactRefresh
|
@viteReactRefresh
|
||||||
@vite(['resources/js/app.tsx'])
|
@vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
|
||||||
@inertiaHead
|
@inertiaHead
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans antialiased">
|
<body class="font-sans antialiased">
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,56 @@
|
||||||
<?php
|
<?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\RegisteredUserController;
|
||||||
|
use App\Http\Controllers\Auth\VerifyEmailController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
// First-run setup only — gated by User::exists() in the controller
|
Route::middleware('guest')->group(function () {
|
||||||
Route::get('register', [RegisteredUserController::class, 'create'])->name('register');
|
Route::get('register', [RegisteredUserController::class, 'create'])
|
||||||
Route::post('register', [RegisteredUserController::class, 'store']);
|
->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');
|
||||||
|
});
|
||||||
|
|
|
||||||
21
routes/settings.php
Normal file
21
routes/settings.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?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');
|
||||||
|
});
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AssetController;
|
use App\Http\Controllers\AssetController;
|
||||||
use App\Http\Controllers\Milestones\MilestoneController;
|
use App\Http\Controllers\Transactions\PurchaseController;
|
||||||
use App\Http\Controllers\Pricing\PricingController;
|
use App\Http\Controllers\Pricing\PricingController;
|
||||||
use App\Http\Controllers\TrackerController;
|
use App\Http\Controllers\Milestones\MilestoneController;
|
||||||
use App\Http\Controllers\Transactions\EntryController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
|
@ -16,25 +15,22 @@
|
||||||
return Inertia::render('dashboard');
|
return Inertia::render('dashboard');
|
||||||
})->name('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
|
// Asset routes
|
||||||
Route::prefix('assets')->name('assets.')->group(function () {
|
Route::prefix('assets')->name('assets.')->group(function () {
|
||||||
Route::get('/', [AssetController::class, 'index'])->name('index');
|
Route::get('/', [AssetController::class, 'index'])->name('index');
|
||||||
Route::post('/', [AssetController::class, 'store'])->name('store');
|
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('/search', [AssetController::class, 'search'])->name('search');
|
||||||
Route::get('/{asset}', [AssetController::class, 'show'])->name('show');
|
Route::get('/{asset}', [AssetController::class, 'show'])->name('show');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Entry routes (replaces purchases)
|
// Purchase routes
|
||||||
Route::prefix('entries')->name('entries.')->group(function () {
|
Route::prefix('purchases')->name('purchases.')->group(function () {
|
||||||
Route::get('/', [EntryController::class, 'index'])->name('index');
|
Route::get('/', [PurchaseController::class, 'index'])->name('index');
|
||||||
Route::post('/', [EntryController::class, 'store'])->name('store');
|
Route::post('/', [PurchaseController::class, 'store'])->name('store');
|
||||||
Route::get('/summary', [EntryController::class, 'summary'])->name('summary');
|
Route::get('/summary', [PurchaseController::class, 'summary'])->name('summary');
|
||||||
Route::delete('/{entry}', [EntryController::class, 'destroy'])->name('destroy');
|
Route::delete('/{purchase}', [PurchaseController::class, 'destroy'])->name('destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pricing routes
|
// Pricing routes
|
||||||
|
|
@ -51,4 +47,5 @@
|
||||||
Route::post('/', [MilestoneController::class, 'store'])->name('store');
|
Route::post('/', [MilestoneController::class, 'store'])->name('store');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
require __DIR__.'/settings.php';
|
||||||
require __DIR__.'/auth.php';
|
require __DIR__.'/auth.php';
|
||||||
|
|
|
||||||
148
shell.nix
148
shell.nix
|
|
@ -1,148 +0,0 @@
|
||||||
{ pkgs ? import <nixpkgs> {} }:
|
|
||||||
|
|
||||||
pkgs.mkShell {
|
|
||||||
buildInputs = with pkgs; [
|
|
||||||
# PHP and tools
|
|
||||||
php83
|
|
||||||
php83Packages.composer
|
|
||||||
|
|
||||||
# Node.js (needed for Tailwind CSS / Vite)
|
|
||||||
nodejs_22
|
|
||||||
|
|
||||||
# Container tools
|
|
||||||
podman
|
|
||||||
podman-compose
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
git
|
|
||||||
curl
|
|
||||||
gnumake
|
|
||||||
];
|
|
||||||
|
|
||||||
shellHook = ''
|
|
||||||
export USER_ID=$(id -u)
|
|
||||||
export GROUP_ID=$(id -g)
|
|
||||||
export PODMAN_USERNS=keep-id
|
|
||||||
|
|
||||||
# Compose file location
|
|
||||||
COMPOSE_FILE="$PWD/docker/dev/docker-compose.yml"
|
|
||||||
|
|
||||||
# ===================
|
|
||||||
# ALIASES
|
|
||||||
# ===================
|
|
||||||
alias pc='podman-compose -f $COMPOSE_FILE'
|
|
||||||
|
|
||||||
# ===================
|
|
||||||
# DEV COMMANDS
|
|
||||||
# ===================
|
|
||||||
dev-up() {
|
|
||||||
echo "Starting services..."
|
|
||||||
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d "$@"
|
|
||||||
echo ""
|
|
||||||
podman-compose -f $COMPOSE_FILE ps
|
|
||||||
echo ""
|
|
||||||
echo "App available at: http://localhost:8000"
|
|
||||||
}
|
|
||||||
|
|
||||||
dev-down() {
|
|
||||||
if [[ "$1" == "-v" ]]; then
|
|
||||||
echo "Stopping services and removing volumes..."
|
|
||||||
podman-compose -f $COMPOSE_FILE down -v
|
|
||||||
else
|
|
||||||
echo "Stopping services..."
|
|
||||||
podman-compose -f $COMPOSE_FILE down
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
dev-restart() {
|
|
||||||
echo "Restarting services..."
|
|
||||||
podman-compose -f $COMPOSE_FILE restart "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
dev-rebuild() {
|
|
||||||
echo "Rebuilding services (down -v + up --build)..."
|
|
||||||
podman-compose -f $COMPOSE_FILE down -v
|
|
||||||
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d --build "$@"
|
|
||||||
echo ""
|
|
||||||
podman-compose -f $COMPOSE_FILE ps
|
|
||||||
echo ""
|
|
||||||
echo "App available at: http://localhost:8000"
|
|
||||||
}
|
|
||||||
|
|
||||||
dev-logs() {
|
|
||||||
podman-compose -f $COMPOSE_FILE logs -f app "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
dev-logs-db() {
|
|
||||||
podman-compose -f $COMPOSE_FILE logs -f db "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
dev-shell() {
|
|
||||||
podman-compose -f $COMPOSE_FILE exec app bash
|
|
||||||
}
|
|
||||||
|
|
||||||
dev-artisan() {
|
|
||||||
podman-compose -f $COMPOSE_FILE exec app php artisan "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
dev-composer() {
|
|
||||||
podman-compose -f $COMPOSE_FILE exec app composer "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ===================
|
|
||||||
# BUILD COMMANDS
|
|
||||||
# ===================
|
|
||||||
base-build() {
|
|
||||||
local image="forge.lvl0.xyz/lvl0/incr:latest"
|
|
||||||
|
|
||||||
if ! podman login --get-login forge.lvl0.xyz &>/dev/null; then
|
|
||||||
echo "Not logged in to forge.lvl0.xyz"
|
|
||||||
podman login forge.lvl0.xyz || return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Building image: $image"
|
|
||||||
if ! podman build -t "$image" -f Dockerfile .; then
|
|
||||||
echo "Build failed!"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Pushing to registry..."
|
|
||||||
if ! podman push "$image"; then
|
|
||||||
echo "Push failed!"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Done! Image pushed: $image"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ===================
|
|
||||||
# WELCOME MESSAGE
|
|
||||||
# ===================
|
|
||||||
echo ""
|
|
||||||
echo "================================================="
|
|
||||||
echo " incr Dev Environment "
|
|
||||||
echo "================================================="
|
|
||||||
echo ""
|
|
||||||
echo " Podman: $(podman --version | cut -d' ' -f3)"
|
|
||||||
echo ""
|
|
||||||
echo "Commands:"
|
|
||||||
echo " dev-up [services] Start all or specific services"
|
|
||||||
echo " dev-down [-v] Stop services (-v removes volumes)"
|
|
||||||
echo " dev-rebuild Fresh start (down -v + up)"
|
|
||||||
echo " dev-restart Restart services"
|
|
||||||
echo " dev-logs Tail app logs"
|
|
||||||
echo " dev-logs-db Tail database logs"
|
|
||||||
echo " dev-shell Shell into app container"
|
|
||||||
echo " dev-artisan <cmd> Run artisan command"
|
|
||||||
echo " dev-composer <cmd> Run composer command"
|
|
||||||
echo " base-build Build and push image"
|
|
||||||
echo ""
|
|
||||||
echo "Services:"
|
|
||||||
echo " app Laravel http://localhost:8000"
|
|
||||||
echo " vite HMR http://localhost:5173"
|
|
||||||
echo " db MySQL localhost:3307"
|
|
||||||
echo ""
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
54
tests/Feature/Auth/AuthenticationTest.php
Normal file
54
tests/Feature/Auth/AuthenticationTest.php
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AuthenticationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_login_screen_can_be_rendered()
|
||||||
|
{
|
||||||
|
$response = $this->get('/login');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_users_can_authenticate_using_the_login_screen()
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->post('/login', [
|
||||||
|
'email' => $user->email,
|
||||||
|
'password' => 'password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertAuthenticated();
|
||||||
|
$response->assertRedirect(route('dashboard', absolute: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_users_can_not_authenticate_with_invalid_password()
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->post('/login', [
|
||||||
|
'email' => $user->email,
|
||||||
|
'password' => 'wrong-password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertGuest();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_users_can_logout()
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->post('/logout');
|
||||||
|
|
||||||
|
$this->assertGuest();
|
||||||
|
$response->assertRedirect('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
58
tests/Feature/Auth/EmailVerificationTest.php
Normal file
58
tests/Feature/Auth/EmailVerificationTest.php
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Events\Verified;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class EmailVerificationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_email_verification_screen_can_be_rendered()
|
||||||
|
{
|
||||||
|
$user = User::factory()->unverified()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->get('/verify-email');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_email_can_be_verified()
|
||||||
|
{
|
||||||
|
$user = User::factory()->unverified()->create();
|
||||||
|
|
||||||
|
Event::fake();
|
||||||
|
|
||||||
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
|
'verification.verify',
|
||||||
|
now()->addMinutes(60),
|
||||||
|
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->get($verificationUrl);
|
||||||
|
|
||||||
|
Event::assertDispatched(Verified::class);
|
||||||
|
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
||||||
|
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_email_is_not_verified_with_invalid_hash()
|
||||||
|
{
|
||||||
|
$user = User::factory()->unverified()->create();
|
||||||
|
|
||||||
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
|
'verification.verify',
|
||||||
|
now()->addMinutes(60),
|
||||||
|
['id' => $user->id, 'hash' => sha1('wrong-email')]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($user)->get($verificationUrl);
|
||||||
|
|
||||||
|
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||||
|
}
|
||||||
|
}
|
||||||
44
tests/Feature/Auth/PasswordConfirmationTest.php
Normal file
44
tests/Feature/Auth/PasswordConfirmationTest.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PasswordConfirmationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_confirm_password_screen_can_be_rendered()
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->get('/confirm-password');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_password_can_be_confirmed()
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->post('/confirm-password', [
|
||||||
|
'password' => 'password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect();
|
||||||
|
$response->assertSessionHasNoErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_password_is_not_confirmed_with_invalid_password()
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->post('/confirm-password', [
|
||||||
|
'password' => 'wrong-password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasErrors();
|
||||||
|
}
|
||||||
|
}
|
||||||
73
tests/Feature/Auth/PasswordResetTest.php
Normal file
73
tests/Feature/Auth/PasswordResetTest.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Notifications\ResetPassword;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PasswordResetTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_reset_password_link_screen_can_be_rendered()
|
||||||
|
{
|
||||||
|
$response = $this->get('/forgot-password');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reset_password_link_can_be_requested()
|
||||||
|
{
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->post('/forgot-password', ['email' => $user->email]);
|
||||||
|
|
||||||
|
Notification::assertSentTo($user, ResetPassword::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reset_password_screen_can_be_rendered()
|
||||||
|
{
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->post('/forgot-password', ['email' => $user->email]);
|
||||||
|
|
||||||
|
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
|
||||||
|
$response = $this->get('/reset-password/'.$notification->token);
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_password_can_be_reset_with_valid_token()
|
||||||
|
{
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->post('/forgot-password', ['email' => $user->email]);
|
||||||
|
|
||||||
|
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
|
||||||
|
$response = $this->post('/reset-password', [
|
||||||
|
'token' => $notification->token,
|
||||||
|
'email' => $user->email,
|
||||||
|
'password' => 'password',
|
||||||
|
'password_confirmation' => 'password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertSessionHasNoErrors()
|
||||||
|
->assertRedirect(route('login'));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
31
tests/Feature/Auth/RegistrationTest.php
Normal file
31
tests/Feature/Auth/RegistrationTest.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class RegistrationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_registration_screen_can_be_rendered()
|
||||||
|
{
|
||||||
|
$response = $this->get('/register');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_new_users_can_register()
|
||||||
|
{
|
||||||
|
$response = $this->post('/register', [
|
||||||
|
'name' => 'Test User',
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
'password' => 'password',
|
||||||
|
'password_confirmation' => 'password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertAuthenticated();
|
||||||
|
$response->assertRedirect(route('dashboard', absolute: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
24
tests/Feature/DashboardTest.php
Normal file
24
tests/Feature/DashboardTest.php
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class DashboardTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_guests_are_redirected_to_the_login_page()
|
||||||
|
{
|
||||||
|
$this->get('/dashboard')->assertRedirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_authenticated_users_can_visit_the_dashboard()
|
||||||
|
{
|
||||||
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
|
||||||
|
$this->get('/dashboard')->assertOk();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,6 @@
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Models\Milestone;
|
use App\Models\Milestone;
|
||||||
use App\Models\Tracker;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
|
@ -12,28 +10,16 @@ class MilestoneTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
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
|
public function test_can_create_milestone(): void
|
||||||
{
|
{
|
||||||
$tracker = $this->tracker();
|
|
||||||
|
|
||||||
$milestone = Milestone::create([
|
$milestone = Milestone::create([
|
||||||
'tracker_id' => $tracker->id,
|
|
||||||
'target' => 1500,
|
'target' => 1500,
|
||||||
'description' => 'First milestone',
|
'description' => 'First milestone'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('milestones', [
|
$this->assertDatabaseHas('milestones', [
|
||||||
'target' => 1500,
|
'target' => 1500,
|
||||||
'description' => 'First milestone',
|
'description' => 'First milestone'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertEquals(1500, $milestone->target);
|
$this->assertEquals(1500, $milestone->target);
|
||||||
|
|
@ -42,9 +28,9 @@ public function test_can_create_milestone(): void
|
||||||
|
|
||||||
public function test_can_fetch_milestones_via_api(): void
|
public function test_can_fetch_milestones_via_api(): void
|
||||||
{
|
{
|
||||||
$tracker = $this->tracker();
|
// Create test milestones
|
||||||
Milestone::create(['tracker_id' => $tracker->id, 'target' => 1500, 'description' => 'First milestone']);
|
Milestone::create(['target' => 1500, 'description' => 'First milestone']);
|
||||||
Milestone::create(['tracker_id' => $tracker->id, 'target' => 3000, 'description' => 'Second milestone']);
|
Milestone::create(['target' => 3000, 'description' => 'Second milestone']);
|
||||||
|
|
||||||
$response = $this->get('/milestones');
|
$response = $this->get('/milestones');
|
||||||
|
|
||||||
|
|
@ -52,16 +38,16 @@ public function test_can_fetch_milestones_via_api(): void
|
||||||
$response->assertJsonCount(2);
|
$response->assertJsonCount(2);
|
||||||
$response->assertJson([
|
$response->assertJson([
|
||||||
['target' => 1500, 'description' => 'First milestone'],
|
['target' => 1500, 'description' => 'First milestone'],
|
||||||
['target' => 3000, 'description' => 'Second milestone'],
|
['target' => 3000, 'description' => 'Second milestone']
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_milestones_ordered_by_target(): void
|
public function test_milestones_ordered_by_target(): void
|
||||||
{
|
{
|
||||||
$tracker = $this->tracker();
|
// Create milestones in reverse order
|
||||||
Milestone::create(['tracker_id' => $tracker->id, 'target' => 3000, 'description' => 'Third']);
|
Milestone::create(['target' => 3000, 'description' => 'Third']);
|
||||||
Milestone::create(['tracker_id' => $tracker->id, 'target' => 1000, 'description' => 'First']);
|
Milestone::create(['target' => 1000, 'description' => 'First']);
|
||||||
Milestone::create(['tracker_id' => $tracker->id, 'target' => 2000, 'description' => 'Second']);
|
Milestone::create(['target' => 2000, 'description' => 'Second']);
|
||||||
|
|
||||||
$response = $this->get('/milestones');
|
$response = $this->get('/milestones');
|
||||||
|
|
||||||
|
|
|
||||||
51
tests/Feature/Settings/PasswordUpdateTest.php
Normal file
51
tests/Feature/Settings/PasswordUpdateTest.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Settings;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PasswordUpdateTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_password_can_be_updated()
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this
|
||||||
|
->actingAs($user)
|
||||||
|
->from('/settings/password')
|
||||||
|
->put('/settings/password', [
|
||||||
|
'current_password' => 'password',
|
||||||
|
'password' => 'new-password',
|
||||||
|
'password_confirmation' => 'new-password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertSessionHasNoErrors()
|
||||||
|
->assertRedirect('/settings/password');
|
||||||
|
|
||||||
|
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_correct_password_must_be_provided_to_update_password()
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this
|
||||||
|
->actingAs($user)
|
||||||
|
->from('/settings/password')
|
||||||
|
->put('/settings/password', [
|
||||||
|
'current_password' => 'wrong-password',
|
||||||
|
'password' => 'new-password',
|
||||||
|
'password_confirmation' => 'new-password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertSessionHasErrors('current_password')
|
||||||
|
->assertRedirect('/settings/password');
|
||||||
|
}
|
||||||
|
}
|
||||||
99
tests/Feature/Settings/ProfileUpdateTest.php
Normal file
99
tests/Feature/Settings/ProfileUpdateTest.php
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Settings;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProfileUpdateTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_profile_page_is_displayed()
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this
|
||||||
|
->actingAs($user)
|
||||||
|
->get('/settings/profile');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_profile_information_can_be_updated()
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this
|
||||||
|
->actingAs($user)
|
||||||
|
->patch('/settings/profile', [
|
||||||
|
'name' => 'Test User',
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertSessionHasNoErrors()
|
||||||
|
->assertRedirect('/settings/profile');
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
|
||||||
|
$this->assertSame('Test User', $user->name);
|
||||||
|
$this->assertSame('test@example.com', $user->email);
|
||||||
|
$this->assertNull($user->email_verified_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged()
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this
|
||||||
|
->actingAs($user)
|
||||||
|
->patch('/settings/profile', [
|
||||||
|
'name' => 'Test User',
|
||||||
|
'email' => $user->email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertSessionHasNoErrors()
|
||||||
|
->assertRedirect('/settings/profile');
|
||||||
|
|
||||||
|
$this->assertNotNull($user->refresh()->email_verified_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_can_delete_their_account()
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this
|
||||||
|
->actingAs($user)
|
||||||
|
->delete('/settings/profile', [
|
||||||
|
'password' => 'password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertSessionHasNoErrors()
|
||||||
|
->assertRedirect('/');
|
||||||
|
|
||||||
|
$this->assertGuest();
|
||||||
|
$this->assertNull($user->fresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_correct_password_must_be_provided_to_delete_account()
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this
|
||||||
|
->actingAs($user)
|
||||||
|
->from('/settings/profile')
|
||||||
|
->delete('/settings/profile', [
|
||||||
|
'password' => 'wrong-password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertSessionHasErrors('password')
|
||||||
|
->assertRedirect('/settings/profile');
|
||||||
|
|
||||||
|
$this->assertNotNull($user->fresh());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,18 +5,6 @@ import { resolve } from 'node:path';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
|
||||||
host: '0.0.0.0',
|
|
||||||
port: 5173,
|
|
||||||
hmr: {
|
|
||||||
host: 'localhost',
|
|
||||||
clientPort: 5173,
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
usePolling: true,
|
|
||||||
ignored: ['**/storage/framework/views/**'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [
|
plugins: [
|
||||||
laravel({
|
laravel({
|
||||||
input: ['resources/css/app.css', 'resources/js/app.tsx'],
|
input: ['resources/css/app.css', 'resources/js/app.tsx'],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue