Compare commits
No commits in common. "1fecc2b47fd0c406a51401f4c961b4b5e2d51c06" and "8ce8a20d356e7e7260555a8eb7add8667b4418f9" have entirely different histories.
1fecc2b47f
...
8ce8a20d35
86 changed files with 9687 additions and 1468 deletions
|
|
@ -13,6 +13,7 @@ APP_MAINTENANCE_DRIVER=file
|
||||||
|
|
||||||
PHP_CLI_SERVER_WORKERS=4
|
PHP_CLI_SERVER_WORKERS=4
|
||||||
|
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stack
|
||||||
LOG_STACK=single
|
LOG_STACK=single
|
||||||
|
|
@ -26,7 +27,7 @@ DB_DATABASE=incr
|
||||||
DB_USERNAME=incr_user
|
DB_USERNAME=incr_user
|
||||||
DB_PASSWORD=change_me_in_production
|
DB_PASSWORD=change_me_in_production
|
||||||
|
|
||||||
SESSION_DRIVER=cookie
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=true
|
SESSION_ENCRYPT=true
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@ on:
|
||||||
push:
|
push:
|
||||||
branches: ['release/*']
|
branches: ['release/*']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, 'release/*']
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: forge.lvl0.xyz/lvl0/incr-ci:php8.3-1
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
|
|
@ -24,10 +24,17 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- 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
|
- name: Cache Composer dependencies
|
||||||
uses: https://data.forgejo.org/actions/cache@v4
|
uses: https://data.forgejo.org/actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/composer
|
path: ~/.composer/cache
|
||||||
key: composer-${{ hashFiles('composer.lock') }}
|
key: composer-${{ hashFiles('composer.lock') }}
|
||||||
restore-keys: composer-
|
restore-keys: composer-
|
||||||
|
|
||||||
|
|
@ -51,9 +58,6 @@ jobs:
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: vendor/bin/pint --test
|
run: vendor/bin/pint --test
|
||||||
|
|
||||||
- name: Static analysis
|
|
||||||
run: vendor/bin/phpstan analyse --memory-limit=1G --no-progress --error-format=github
|
|
||||||
|
|
||||||
- name: Tests
|
- name: Tests
|
||||||
run: php artisan test --coverage-clover coverage.xml --coverage-text
|
run: php artisan test --coverage-clover coverage.xml --coverage-text
|
||||||
|
|
||||||
|
|
@ -127,7 +131,7 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: node:22-bookworm-slim
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
name: Build and Push CI Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'docker/build/**'
|
|
||||||
- '.forgejo/workflows/images.yml'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
images:
|
|
||||||
runs-on: docker
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: incr-ci
|
|
||||||
file: docker/build/Dockerfile.ci
|
|
||||||
version: php8.3-1
|
|
||||||
steps:
|
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
|
||||||
|
|
||||||
- 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: Build and push
|
|
||||||
uses: https://data.forgejo.org/docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ${{ matrix.file }}
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
forge.lvl0.xyz/lvl0/${{ matrix.name }}:${{ matrix.version }}
|
|
||||||
forge.lvl0.xyz/lvl0/${{ matrix.name }}:latest
|
|
||||||
forge.lvl0.xyz/lvl0/${{ matrix.name }}:${{ github.sha }}
|
|
||||||
45
.github/workflows/lint.yml
vendored
Normal file
45
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
name: linter
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.4'
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Run Pint
|
||||||
|
run: vendor/bin/pint
|
||||||
|
|
||||||
|
- name: Format Frontend
|
||||||
|
run: npm run format
|
||||||
|
|
||||||
|
- name: Lint Frontend
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
# - name: Commit Changes
|
||||||
|
# uses: stefanzweifel/git-auto-commit-action@v5
|
||||||
|
# with:
|
||||||
|
# commit_message: fix code style
|
||||||
|
# commit_options: '--no-verify'
|
||||||
50
.github/workflows/tests.yml
vendored
Normal file
50
.github/workflows/tests.yml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
name: tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: 8.4
|
||||||
|
tools: composer:v2
|
||||||
|
coverage: xdebug
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install Node Dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build Assets
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Copy Environment File
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Generate Application Key
|
||||||
|
run: php artisan key:generate
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: ./vendor/bin/phpunit
|
||||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
resources/js/components/ui/*
|
||||||
|
resources/js/ziggy.js
|
||||||
|
resources/views/mail/*
|
||||||
19
.prettierrc
Normal file
19
.prettierrc
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"singleAttributePerLine": false,
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"printWidth": 150,
|
||||||
|
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"],
|
||||||
|
"tailwindFunctions": ["clsx", "cn"],
|
||||||
|
"tailwindStylesheet": "resources/css/app.css",
|
||||||
|
"tabWidth": 4,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "**/*.yml",
|
||||||
|
"options": {
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
63
Jenkinsfile
vendored
Normal file
63
Jenkinsfile
vendored
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
triggers {
|
||||||
|
GenericTrigger(
|
||||||
|
causeString: 'Triggered on tag push',
|
||||||
|
token: 'tag-trigger-secret',
|
||||||
|
printContributedVariables: true,
|
||||||
|
printPostContent: true,
|
||||||
|
regexpFilterExpression: 'ref=refs/tags/.*\nafter=(?!0{40}).*',
|
||||||
|
regexpFilterText: '$ref\n$after',
|
||||||
|
genericVariables: [
|
||||||
|
[key: 'ref', value: '$.ref'],
|
||||||
|
[key: 'after', value: '$.after']
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
REGISTRY = 'codeberg.org'
|
||||||
|
IMAGE_NAME = "${REGISTRY}/lvl0/incr"
|
||||||
|
DOCKER_CREDENTIALS_ID = 'codeberg-registry'
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Tag Push Filter') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
if (!env.ref?.startsWith('refs/tags/')) {
|
||||||
|
echo "Not a tag push (ref = ${env.ref}). Skipping build."
|
||||||
|
currentBuild.result = 'NOT_BUILT'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build & Push Docker Image') {
|
||||||
|
when {
|
||||||
|
expression {
|
||||||
|
return env.ref?.startsWith('refs/tags/') && env.after != null && env.after != "0000000000000000000000000000000000000000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def tagName = env.ref.replaceFirst(/^refs\/tags\//, '')
|
||||||
|
def cleanedTag = tagName.replaceFirst(/^v/, '')
|
||||||
|
|
||||||
|
sh "docker build -t $IMAGE_NAME:$cleanedTag -f docker/Dockerfile ."
|
||||||
|
|
||||||
|
withCredentials([usernamePassword(credentialsId: "$DOCKER_CREDENTIALS_ID", usernameVariable: 'USERNAME', passwordVariable: 'PASSWORD')]) {
|
||||||
|
sh """
|
||||||
|
echo "$PASSWORD" | docker login $REGISTRY -u "$USERNAME" --password-stdin
|
||||||
|
docker push $IMAGE_NAME:$cleanedTag
|
||||||
|
docker tag $IMAGE_NAME:$cleanedTag $IMAGE_NAME:latest
|
||||||
|
docker push $IMAGE_NAME:latest
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
README.md
27
README.md
|
|
@ -2,34 +2,35 @@
|
||||||
|
|
||||||
# incr
|
# incr
|
||||||
|
|
||||||
**A minimalist counter**
|
**A minimalist counter with milestone-driven progress**
|
||||||
|
|
||||||
*Track anything you accumulate — click to increment*
|
*Track anything you accumulate — with visual progress and milestone reinforcement*
|
||||||
|
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
[](https://laravel.com/)
|
[](https://laravel.com/)
|
||||||
[](https://livewire.laravel.com/)
|
[](https://reactjs.org/)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
incr is a minimalist, self-hosted counter. Pick something you want to accumulate — books, workouts, anything — set a starting value, then click the number to count up.
|
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.
|
||||||
|
|
||||||
It features a distinctive LED-style digital display and nothing else.
|
It features a distinctive LED-style digital display, configurable milestones, and optional price tracking.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **LED-style display** — large red digital counter
|
- **LED-style display** — large red digital counter
|
||||||
- **Click to increment** — click the number to add to it
|
- **Milestone tracking** — set targets, cycle through them as you progress
|
||||||
- **Set a value directly** — for when you need to add more than one
|
- **Purchase logging** — add entries with date and optional price
|
||||||
|
- **Price tracking** — optional; log a current price to see portfolio value and P/L
|
||||||
- **Self-hosted** — your data stays on your server
|
- **Self-hosted** — your data stays on your server
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Laravel 13 (PHP 8.3+) with MySQL
|
- **Backend**: Laravel 12 (PHP 8.3+) with MySQL
|
||||||
- **Frontend**: Livewire 4 with Blade
|
- **Frontend**: React 19 + TypeScript with Inertia.js
|
||||||
- **Styling**: Tailwind CSS 4
|
- **Styling**: Tailwind CSS 4 with shadcn/ui components
|
||||||
- **Deployment**: Docker / Podman with multi-stage builds
|
- **Deployment**: Docker / Podman with multi-stage builds
|
||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
@ -100,9 +101,7 @@ ## Development
|
||||||
dev-up
|
dev-up
|
||||||
```
|
```
|
||||||
|
|
||||||
Available commands inside the shell: `dev-up`, `dev-down`, `dev-restart`, `dev-rebuild`, `dev-logs`, `dev-logs-db`, `dev-shell`, `dev-artisan`, `dev-composer`, `base-build`.
|
Available commands inside the shell: `dev-up`, `dev-down`, `dev-rebuild`, `dev-logs`, `dev-shell`, `dev-artisan`, `dev-composer`.
|
||||||
|
|
||||||
The dev stack binds host ports 8000, 5173 and 3307. Only one project using these can run at a time.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
63
app/Http/Controllers/AssetController.php
Normal file
63
app/Http/Controllers/AssetController.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Asset;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AssetController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(Asset::orderBy('symbol')->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'symbol' => 'required|string|max:10|unique:assets,symbol',
|
||||||
|
'full_name' => 'nullable|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$asset = Asset::create([
|
||||||
|
'symbol' => strtoupper($validated['symbol']),
|
||||||
|
'full_name' => $validated['full_name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Asset created successfully!',
|
||||||
|
'asset' => $asset,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Asset $asset): JsonResponse
|
||||||
|
{
|
||||||
|
$asset->load('assetPrices');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'asset' => $asset,
|
||||||
|
'current_price' => $asset->currentPrice(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = $request->get('q');
|
||||||
|
|
||||||
|
if (! $query) {
|
||||||
|
return response()->json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$assets = Asset::where('symbol', 'like', "%{$query}%")
|
||||||
|
->orWhere('full_name', 'like', "%{$query}%")
|
||||||
|
->orderBy('symbol')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json($assets);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
60
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Events\Registered;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rules;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class RegisteredUserController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the registration page.
|
||||||
|
*/
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
if (User::exists()) {
|
||||||
|
abort(403, 'Registration is disabled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('auth/register');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming registration request.
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if (User::exists()) {
|
||||||
|
abort(403, 'Registration is disabled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
|
||||||
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::forceCreate([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'email' => $validated['email'],
|
||||||
|
'password' => Hash::make($validated['password']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
event(new Registered($user));
|
||||||
|
|
||||||
|
Auth::login($user);
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Http/Controllers/Milestones/MilestoneController.php
Normal file
43
app/Http/Controllers/Milestones/MilestoneController.php
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Milestones;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class MilestoneController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'target' => 'required|integer|min:1',
|
||||||
|
'description' => 'required|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tracker = User::default()->tracker;
|
||||||
|
|
||||||
|
if (! $tracker) {
|
||||||
|
return back()->withErrors(['tracker' => 'No tracker found. Please complete onboarding first.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracker->milestones()->create($validated);
|
||||||
|
|
||||||
|
return back()->with('success', 'Milestone created successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$tracker = User::default()->tracker;
|
||||||
|
|
||||||
|
if (! $tracker) {
|
||||||
|
return response()->json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($tracker->milestones()->orderBy('target')->get());
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Http/Controllers/Pricing/PricingController.php
Normal file
66
app/Http/Controllers/Pricing/PricingController.php
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Pricing;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Pricing\AssetPrice;
|
||||||
|
use App\Models\Tracker;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PricingController extends Controller
|
||||||
|
{
|
||||||
|
private ?Tracker $tracker;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->tracker = User::default()->tracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function current(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'current_price' => AssetPrice::current($this->tracker?->asset_id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'date' => 'required|date|before_or_equal:today',
|
||||||
|
'price' => 'required|numeric|min:0.0001',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $this->tracker?->asset_id) {
|
||||||
|
return back()->withErrors(['asset' => 'Please set an asset first.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetPrice::updatePrice($this->tracker->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!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function history(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$limit = min(max(1, $request->integer('limit', 30)), 365);
|
||||||
|
|
||||||
|
return response()->json(AssetPrice::history($this->tracker?->asset_id, $limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forDate(Request $request, string $date): JsonResponse
|
||||||
|
{
|
||||||
|
validator(['date' => $date], ['date' => 'required|date_format:Y-m-d'])->validate();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'date' => $date,
|
||||||
|
'price' => AssetPrice::forDate($date, $this->tracker?->asset_id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
app/Http/Controllers/TrackerController.php
Normal file
101
app/Http/Controllers/TrackerController.php
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<?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' => 'sometimes|string|max:255',
|
||||||
|
'unit' => 'sometimes|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'] ?? 'Counter',
|
||||||
|
'unit' => $validated['unit'] ?? 'units',
|
||||||
|
'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();
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/Http/Controllers/Transactions/EntryController.php
Normal file
91
app/Http/Controllers/Transactions/EntryController.php
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Transactions;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Transactions\Entry;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class EntryController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$tracker = User::default()->tracker;
|
||||||
|
|
||||||
|
if (! $tracker) {
|
||||||
|
return response()->json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($tracker->entries()->orderBy('date', 'desc')->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'date' => 'required|date|before_or_equal:today',
|
||||||
|
'quantity' => 'required|numeric|min:0.000001',
|
||||||
|
'unit_price' => 'nullable|numeric|min:0.01',
|
||||||
|
'total_cost' => 'nullable|numeric|min:0.01',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tracker = User::default()->tracker;
|
||||||
|
|
||||||
|
if (! $tracker) {
|
||||||
|
return back()->withErrors(['tracker' => 'No tracker found. Please complete onboarding first.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If unit_price and total_cost provided, verify the calculation
|
||||||
|
if (isset($validated['unit_price'], $validated['total_cost'])) {
|
||||||
|
$calculatedTotal = $validated['quantity'] * $validated['unit_price'];
|
||||||
|
if (abs($calculatedTotal - $validated['total_cost']) > 0.01) {
|
||||||
|
return back()->withErrors([
|
||||||
|
'total_cost' => 'Total cost does not match quantity × unit price.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracker->entries()->create($validated);
|
||||||
|
|
||||||
|
return back()->with('success', 'Entry added successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function summary(): JsonResponse
|
||||||
|
{
|
||||||
|
$tracker = User::default()->tracker;
|
||||||
|
|
||||||
|
if (! $tracker) {
|
||||||
|
return response()->json([
|
||||||
|
'total_quantity' => 0,
|
||||||
|
'total_cost' => 0,
|
||||||
|
'average_cost_per_unit' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'total_quantity' => Entry::totalQuantity($tracker->id),
|
||||||
|
'total_cost' => Entry::totalCost($tracker->id),
|
||||||
|
'average_cost_per_unit' => Entry::averageCostPerUnit($tracker->id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Entry $entry): JsonResponse
|
||||||
|
{
|
||||||
|
$tracker = User::default()->tracker;
|
||||||
|
|
||||||
|
if (! $tracker || $entry->tracker_id !== $tracker->id) {
|
||||||
|
return response()->json(['error' => 'Entry not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry->delete();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Entry deleted successfully!',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Http/Middleware/HandleAppearance.php
Normal file
23
app/Http/Middleware/HandleAppearance.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class HandleAppearance
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Http/Middleware/HandleInertiaRequests.php
Normal file
56
app/Http/Middleware/HandleInertiaRequests.php
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Inspiring;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Middleware;
|
||||||
|
use Tighten\Ziggy\Ziggy;
|
||||||
|
|
||||||
|
class HandleInertiaRequests extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The root template that's loaded on the first page visit.
|
||||||
|
*
|
||||||
|
* @see https://inertiajs.com/server-side-setup#root-template
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $rootView = 'app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the current asset version.
|
||||||
|
*
|
||||||
|
* @see https://inertiajs.com/asset-versioning
|
||||||
|
*/
|
||||||
|
public function version(Request $request): ?string
|
||||||
|
{
|
||||||
|
return parent::version($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the props that are shared by default.
|
||||||
|
*
|
||||||
|
* @see https://inertiajs.com/shared-data
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function share(Request $request): array
|
||||||
|
{
|
||||||
|
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
|
||||||
|
|
||||||
|
return [
|
||||||
|
...parent::share($request),
|
||||||
|
'name' => config('app.name'),
|
||||||
|
'quote' => ['message' => trim($message), 'author' => trim($author)],
|
||||||
|
'auth' => [
|
||||||
|
'user' => $request->user()?->only(['id', 'name', 'email']),
|
||||||
|
],
|
||||||
|
'ziggy' => fn (): array => [
|
||||||
|
...(new Ziggy)->toArray(),
|
||||||
|
'location' => $request->url(),
|
||||||
|
],
|
||||||
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Http/Requests/Auth/LoginRequest.php
Normal file
86
app/Http/Requests/Auth/LoginRequest.php
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Auth\Events\Lockout;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class LoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to authenticate the request's credentials.
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function authenticate(): void
|
||||||
|
{
|
||||||
|
$this->ensureIsNotRateLimited();
|
||||||
|
|
||||||
|
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||||
|
RateLimiter::hit($this->throttleKey());
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => __('auth.failed'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::clear($this->throttleKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the login request is not rate limited.
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function ensureIsNotRateLimited(): void
|
||||||
|
{
|
||||||
|
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event(new Lockout($this));
|
||||||
|
|
||||||
|
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => __('auth.throttle', [
|
||||||
|
'seconds' => $seconds,
|
||||||
|
'minutes' => ceil($seconds / 60),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the rate limiting throttle key for the request.
|
||||||
|
*/
|
||||||
|
public function throttleKey(): string
|
||||||
|
{
|
||||||
|
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Requests/Settings/ProfileUpdateRequest.php
Normal file
32
app/Http/Requests/Settings/ProfileUpdateRequest.php
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Settings;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ProfileUpdateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'lowercase',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique(User::class)->ignore($this->user()->id),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Models\Tracker;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Livewire\Attributes\Locked;
|
|
||||||
use Livewire\Attributes\Validate;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Counter extends Component
|
|
||||||
{
|
|
||||||
// Ceiling of the unsigned int column backing trackers.count.
|
|
||||||
public const MAX_COUNT = 4294967295;
|
|
||||||
|
|
||||||
#[Locked]
|
|
||||||
public int $count = 0;
|
|
||||||
|
|
||||||
#[Locked]
|
|
||||||
public bool $needsOnboarding = false;
|
|
||||||
|
|
||||||
public bool $editing = false;
|
|
||||||
|
|
||||||
#[Validate('required|integer|min:0|max:'.self::MAX_COUNT)]
|
|
||||||
public ?int $value = null;
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$tracker = Tracker::current();
|
|
||||||
|
|
||||||
$this->needsOnboarding = $tracker === null;
|
|
||||||
$this->count = $tracker === null ? 0 : $tracker->count;
|
|
||||||
$this->value = $this->count;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function initialise(): void
|
|
||||||
{
|
|
||||||
$this->validate();
|
|
||||||
|
|
||||||
$tracker = Tracker::current() ?? Tracker::create([
|
|
||||||
'label' => 'Counter',
|
|
||||||
'unit' => 'units',
|
|
||||||
'count' => $this->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->count = $tracker->count;
|
|
||||||
$this->needsOnboarding = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function increment(): void
|
|
||||||
{
|
|
||||||
$tracker = Tracker::current();
|
|
||||||
|
|
||||||
if (! $tracker || $tracker->count >= self::MAX_COUNT) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tracker->increment('count');
|
|
||||||
$tracker->refresh();
|
|
||||||
|
|
||||||
$this->count = $tracker->count;
|
|
||||||
$this->value = $this->count;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function edit(): void
|
|
||||||
{
|
|
||||||
$this->value = $this->count;
|
|
||||||
$this->resetValidation();
|
|
||||||
$this->editing = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save(): void
|
|
||||||
{
|
|
||||||
$this->validate();
|
|
||||||
|
|
||||||
$tracker = Tracker::current();
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tracker->update(['count' => $this->value]);
|
|
||||||
|
|
||||||
$this->count = (int) $this->value;
|
|
||||||
$this->editing = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function cancel(): void
|
|
||||||
{
|
|
||||||
$this->value = $this->count;
|
|
||||||
$this->resetValidation();
|
|
||||||
$this->editing = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
return view('livewire.counter')->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
63
app/Models/Asset.php
Normal file
63
app/Models/Asset.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static create(array $array)
|
||||||
|
* @method static where(string $string, string $value)
|
||||||
|
* @method static find(int $id)
|
||||||
|
* @method static orderBy(string $string)
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property string $symbol
|
||||||
|
* @property string|null $full_name
|
||||||
|
*/
|
||||||
|
class Asset extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'symbol',
|
||||||
|
'full_name',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'symbol' => 'string',
|
||||||
|
'full_name' => 'string',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function assetPrices(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Pricing\AssetPrice::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentPrice(): ?float
|
||||||
|
{
|
||||||
|
$latestPrice = $this->assetPrices()->latest('date')->first();
|
||||||
|
|
||||||
|
return $latestPrice ? $latestPrice->price : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findBySymbol(string $symbol): ?self
|
||||||
|
{
|
||||||
|
return static::where('symbol', strtoupper($symbol))->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findOrCreateBySymbol(string $symbol, ?string $fullName = null): self
|
||||||
|
{
|
||||||
|
$asset = static::findBySymbol($symbol);
|
||||||
|
|
||||||
|
if (! $asset) {
|
||||||
|
$asset = static::create([
|
||||||
|
'symbol' => strtoupper($symbol),
|
||||||
|
'full_name' => $fullName,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $asset;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Models/Milestone.php
Normal file
28
app/Models/Milestone.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static create(array $array)
|
||||||
|
* @method static orderBy(string $string)
|
||||||
|
*/
|
||||||
|
class Milestone extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'tracker_id',
|
||||||
|
'target',
|
||||||
|
'description',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'target' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tracker(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tracker::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Models/Pricing/AssetPrice.php
Normal file
86
app/Models/Pricing/AssetPrice.php
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Pricing;
|
||||||
|
|
||||||
|
use App\Models\Asset;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static latest(string $string)
|
||||||
|
* @method static where(string $string, string $string1, string $date)
|
||||||
|
* @method static updateOrCreate(string[] $array, float[] $array1)
|
||||||
|
* @method static orderBy(string $string, string $string1)
|
||||||
|
*
|
||||||
|
* @property Carbon $date
|
||||||
|
* @property float $price
|
||||||
|
*/
|
||||||
|
class AssetPrice extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'asset_id',
|
||||||
|
'date',
|
||||||
|
'price',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'date' => 'date',
|
||||||
|
'price' => 'decimal:4',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function asset(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Asset::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function current(?int $assetId = null): ?float
|
||||||
|
{
|
||||||
|
$query = static::latest('date');
|
||||||
|
|
||||||
|
if ($assetId) {
|
||||||
|
$query->where('asset_id', $assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestPrice = $query->first();
|
||||||
|
|
||||||
|
return $latestPrice ? $latestPrice->price : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forDate(string $date, ?int $assetId = null): ?float
|
||||||
|
{
|
||||||
|
$query = static::where('date', '<=', $date)
|
||||||
|
->orderBy('date', 'desc');
|
||||||
|
|
||||||
|
if ($assetId) {
|
||||||
|
$query->where('asset_id', $assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$price = $query->first();
|
||||||
|
|
||||||
|
return $price ? $price->price : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function updatePrice(int $assetId, string $date, float $price): self
|
||||||
|
{
|
||||||
|
return static::updateOrCreate(
|
||||||
|
['asset_id' => $assetId, 'date' => $date],
|
||||||
|
['price' => $price]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function history(?int $assetId = null, int $limit = 30): Collection
|
||||||
|
{
|
||||||
|
$query = static::orderBy('date', 'desc')->limit($limit);
|
||||||
|
|
||||||
|
if ($assetId) {
|
||||||
|
$query->where('asset_id', $assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,30 +4,45 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Database\Factories\TrackerFactory;
|
use App\Models\Transactions\Entry;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Tracker extends Model
|
class Tracker extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TrackerFactory> */
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'asset_id',
|
||||||
'label',
|
'label',
|
||||||
'unit',
|
'unit',
|
||||||
'count',
|
'price_tracking_enabled',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'count' => 'integer',
|
'price_tracking_enabled' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function current(): ?self
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return self::orderBy('id')->first();
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function asset(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Asset::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entries(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Entry::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function milestones(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Milestone::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
app/Models/Transactions/Entry.php
Normal file
53
app/Models/Transactions/Entry.php
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models\Transactions;
|
||||||
|
|
||||||
|
use App\Models\Tracker;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class Entry extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'tracker_id',
|
||||||
|
'date',
|
||||||
|
'quantity',
|
||||||
|
'unit_price',
|
||||||
|
'total_cost',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'date' => 'date',
|
||||||
|
'quantity' => 'decimal:6',
|
||||||
|
'unit_price' => 'decimal:4',
|
||||||
|
'total_cost' => 'decimal:2',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tracker(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tracker::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function totalQuantity(int $trackerId): float
|
||||||
|
{
|
||||||
|
return (float) static::where('tracker_id', $trackerId)->sum('quantity');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function totalCost(int $trackerId): float
|
||||||
|
{
|
||||||
|
return (float) static::where('tracker_id', $trackerId)->sum('total_cost');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function averageCostPerUnit(int $trackerId): float
|
||||||
|
{
|
||||||
|
$totalQuantity = static::totalQuantity($trackerId);
|
||||||
|
$totalCost = static::totalCost($trackerId);
|
||||||
|
|
||||||
|
return $totalQuantity > 0 ? $totalCost / $totalQuantity : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/Models/User.php
Normal file
64
app/Models/User.php
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\UserFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class User extends Authenticatable
|
||||||
|
{
|
||||||
|
/** @use HasFactory<UserFactory> */
|
||||||
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
'remember_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'password' => 'hashed',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tracker(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(Tracker::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function default(): self
|
||||||
|
{
|
||||||
|
return self::firstWhere('email', 'user@incr.local')
|
||||||
|
?? self::forceCreate([
|
||||||
|
'email' => 'user@incr.local',
|
||||||
|
'name' => 'Default User',
|
||||||
|
'password' => bcrypt(Str::random(32)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasCompletedOnboarding(): bool
|
||||||
|
{
|
||||||
|
return $this->hasEntries() && $this->hasMilestones();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasEntries(): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->tracker?->entries()->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasMilestones(): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->tracker?->milestones()->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\HandleAppearance;
|
||||||
|
use App\Http\Middleware\HandleInertiaRequests;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
@ -12,7 +14,11 @@
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
|
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||||
|
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
|
HandleAppearance::class,
|
||||||
|
HandleInertiaRequests::class,
|
||||||
AddLinkHeadersForPreloadedAssets::class,
|
AddLinkHeadersForPreloadedAssets::class,
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
21
components.json
Normal file
21
components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "resources/css/app.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://getcomposer.org/schema.json",
|
"$schema": "https://getcomposer.org/schema.json",
|
||||||
"name": "lvl0/incr",
|
"name": "laravel/react-starter-kit",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"description": "A minimalist counter.",
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"laravel",
|
"laravel",
|
||||||
"framework"
|
"framework"
|
||||||
|
|
@ -10,19 +10,18 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
|
"inertiajs/inertia-laravel": "^3.0",
|
||||||
"laravel/framework": "^13.0",
|
"laravel/framework": "^13.0",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"livewire/livewire": "^4.0"
|
"tightenco/ziggy": "^2.4"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
"larastan/larastan": "^3.5",
|
|
||||||
"laravel/pail": "^1.2.2",
|
"laravel/pail": "^1.2.2",
|
||||||
"laravel/pint": "^1.18",
|
"laravel/pint": "^1.18",
|
||||||
"laravel/sail": "^1.43",
|
"laravel/sail": "^1.43",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.6",
|
||||||
"phpstan/phpstan": "^2.1.32 <2.2",
|
|
||||||
"phpunit/phpunit": "^12.0"
|
"phpunit/phpunit": "^12.0"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
|
@ -57,6 +56,11 @@
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||||
],
|
],
|
||||||
|
"dev:ssr": [
|
||||||
|
"npm run build:ssr",
|
||||||
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
|
||||||
|
],
|
||||||
"test": [
|
"test": [
|
||||||
"@php artisan config:clear --ansi",
|
"@php artisan config:clear --ansi",
|
||||||
"@php artisan test"
|
"@php artisan test"
|
||||||
|
|
|
||||||
405
composer.lock
generated
405
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "9b1957cddc5253df62939093e476f2f8",
|
"content-hash": "a30f9c47c86600aafedfd3862ac984c5",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
|
|
@ -1053,6 +1053,79 @@
|
||||||
],
|
],
|
||||||
"time": "2025-08-22T14:27:06+00:00"
|
"time": "2025-08-22T14:27:06+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "inertiajs/inertia-laravel",
|
||||||
|
"version": "v3.0.6",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/inertiajs/inertia-laravel.git",
|
||||||
|
"reference": "c255b1ea050cf563b240542a76f7f756ccdb2d67"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/c255b1ea050cf563b240542a76f7f756ccdb2d67",
|
||||||
|
"reference": "c255b1ea050cf563b240542a76f7f756ccdb2d67",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"laravel/framework": "^11.0|^12.0|^13.0",
|
||||||
|
"php": "^8.2.0",
|
||||||
|
"symfony/console": "^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"laravel/boost": "<2.2.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"guzzlehttp/guzzle": "^7.2",
|
||||||
|
"larastan/larastan": "^3.0",
|
||||||
|
"laravel/pint": "^1.16",
|
||||||
|
"mockery/mockery": "^1.3.3",
|
||||||
|
"orchestra/testbench": "^9.2|^10.0|^11.0",
|
||||||
|
"phpunit/phpunit": "^11.5|^12.0",
|
||||||
|
"roave/security-advisories": "dev-master"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Inertia\\ServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"./helpers.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Inertia\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jonathan Reinink",
|
||||||
|
"email": "jonathan@reinink.ca",
|
||||||
|
"homepage": "https://reinink.ca"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "The Laravel adapter for Inertia.js.",
|
||||||
|
"keywords": [
|
||||||
|
"inertia",
|
||||||
|
"laravel"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/inertiajs/inertia-laravel/issues",
|
||||||
|
"source": "https://github.com/inertiajs/inertia-laravel/tree/v3.0.6"
|
||||||
|
},
|
||||||
|
"time": "2026-04-10T14:29:45+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/framework",
|
"name": "laravel/framework",
|
||||||
"version": "v13.7.0",
|
"version": "v13.7.0",
|
||||||
|
|
@ -2025,82 +2098,6 @@
|
||||||
],
|
],
|
||||||
"time": "2026-03-08T20:05:35+00:00"
|
"time": "2026-03-08T20:05:35+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "livewire/livewire",
|
|
||||||
"version": "v4.4.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/livewire/livewire.git",
|
|
||||||
"reference": "514b29d5a23594d4e4846494f580268b20c2f11e"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/livewire/livewire/zipball/514b29d5a23594d4e4846494f580268b20c2f11e",
|
|
||||||
"reference": "514b29d5a23594d4e4846494f580268b20c2f11e",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"illuminate/database": "^10.0|^11.0|^12.0|^13.0",
|
|
||||||
"illuminate/routing": "^10.0|^11.0|^12.0|^13.0",
|
|
||||||
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
|
|
||||||
"illuminate/validation": "^10.0|^11.0|^12.0|^13.0",
|
|
||||||
"laravel/prompts": "^0.1.24|^0.2|^0.3",
|
|
||||||
"league/mime-type-detection": "^1.9",
|
|
||||||
"php": "^8.1",
|
|
||||||
"symfony/console": "^6.0|^7.0|^8.0",
|
|
||||||
"symfony/http-kernel": "^6.2|^7.0|^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"calebporzio/sushi": "^2.1",
|
|
||||||
"laravel/framework": "^10.15.0|^11.0|^12.0|^13.0",
|
|
||||||
"mockery/mockery": "^1.3.1",
|
|
||||||
"orchestra/testbench": "^8.21.0|^9.0|^10.0|^11.0",
|
|
||||||
"orchestra/testbench-dusk": "^8.24|^9.1|^10.0|^11.0",
|
|
||||||
"phpunit/phpunit": "^10.4|^11.5|^12.5",
|
|
||||||
"psy/psysh": "^0.11.22|^0.12"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"laravel": {
|
|
||||||
"aliases": {
|
|
||||||
"Livewire": "Livewire\\Livewire"
|
|
||||||
},
|
|
||||||
"providers": [
|
|
||||||
"Livewire\\LivewireServiceProvider"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"files": [
|
|
||||||
"src/helpers.php"
|
|
||||||
],
|
|
||||||
"psr-4": {
|
|
||||||
"Livewire\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Caleb Porzio",
|
|
||||||
"email": "calebporzio@gmail.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "A front-end framework for Laravel.",
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/livewire/livewire/issues",
|
|
||||||
"source": "https://github.com/livewire/livewire/tree/v4.4.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://github.com/livewire",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-08-10T15:24:22+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
|
|
@ -5957,6 +5954,76 @@
|
||||||
],
|
],
|
||||||
"time": "2026-03-30T13:44:50+00:00"
|
"time": "2026-03-30T13:44:50+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "tightenco/ziggy",
|
||||||
|
"version": "v2.6.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/tighten/ziggy.git",
|
||||||
|
"reference": "8a0b645921623f77dceaf543d61ecd51a391d96e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/tighten/ziggy/zipball/8a0b645921623f77dceaf543d61ecd51a391d96e",
|
||||||
|
"reference": "8a0b645921623f77dceaf543d61ecd51a391d96e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"laravel/framework": ">=9.0",
|
||||||
|
"php": ">=8.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/folio": "^1.1",
|
||||||
|
"orchestra/testbench": "^8.0 || ^9.0 || ^10.0",
|
||||||
|
"pestphp/pest": "^2.0 || ^3.0 || ^4.0",
|
||||||
|
"pestphp/pest-plugin-laravel": "^2.0 || ^3.0 || ^4.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Tighten\\Ziggy\\ZiggyServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tighten\\Ziggy\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Daniel Coulbourne",
|
||||||
|
"email": "daniel@tighten.co"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jake Bathman",
|
||||||
|
"email": "jake@tighten.co"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jacob Baker-Kretzmar",
|
||||||
|
"email": "jacob@tighten.co"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Use your Laravel named routes in JavaScript.",
|
||||||
|
"homepage": "https://github.com/tighten/ziggy",
|
||||||
|
"keywords": [
|
||||||
|
"Ziggy",
|
||||||
|
"javascript",
|
||||||
|
"laravel",
|
||||||
|
"routes"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/tighten/ziggy/issues",
|
||||||
|
"source": "https://github.com/tighten/ziggy/tree/v2.6.2"
|
||||||
|
},
|
||||||
|
"time": "2026-03-05T14:41:19+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "tijsverkoyen/css-to-inline-styles",
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
"version": "v2.4.0",
|
"version": "v2.4.0",
|
||||||
|
|
@ -6357,137 +6424,6 @@
|
||||||
},
|
},
|
||||||
"time": "2025-04-30T06:54:44+00:00"
|
"time": "2025-04-30T06:54:44+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "iamcal/sql-parser",
|
|
||||||
"version": "v0.7",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/iamcal/SQLParser.git",
|
|
||||||
"reference": "610392f38de49a44dab08dc1659960a29874c4b8"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8",
|
|
||||||
"reference": "610392f38de49a44dab08dc1659960a29874c4b8",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"php-coveralls/php-coveralls": "^1.0",
|
|
||||||
"phpunit/phpunit": "^5|^6|^7|^8|^9"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"iamcal\\": "src"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Cal Henderson",
|
|
||||||
"email": "cal@iamcal.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "MySQL schema parser",
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/iamcal/SQLParser/issues",
|
|
||||||
"source": "https://github.com/iamcal/SQLParser/tree/v0.7"
|
|
||||||
},
|
|
||||||
"time": "2026-01-28T22:20:33+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "larastan/larastan",
|
|
||||||
"version": "v3.9.6",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/larastan/larastan.git",
|
|
||||||
"reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/larastan/larastan/zipball/9ad17e83e96b63536cb6ac39c3d40d29ff9cf636",
|
|
||||||
"reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-json": "*",
|
|
||||||
"iamcal/sql-parser": "^0.7.0",
|
|
||||||
"illuminate/console": "^11.44.2 || ^12.4.1 || ^13",
|
|
||||||
"illuminate/container": "^11.44.2 || ^12.4.1 || ^13",
|
|
||||||
"illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13",
|
|
||||||
"illuminate/database": "^11.44.2 || ^12.4.1 || ^13",
|
|
||||||
"illuminate/http": "^11.44.2 || ^12.4.1 || ^13",
|
|
||||||
"illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13",
|
|
||||||
"illuminate/support": "^11.44.2 || ^12.4.1 || ^13",
|
|
||||||
"php": "^8.2",
|
|
||||||
"phpstan/phpstan": "^2.1.44"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"doctrine/coding-standard": "^13",
|
|
||||||
"laravel/framework": "^11.44.2 || ^12.7.2 || ^13",
|
|
||||||
"mockery/mockery": "^1.6.12",
|
|
||||||
"nikic/php-parser": "^5.4",
|
|
||||||
"orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11",
|
|
||||||
"orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11",
|
|
||||||
"phpstan/phpstan-deprecation-rules": "^2.0.1",
|
|
||||||
"phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"orchestra/testbench": "Using Larastan for analysing a package needs Testbench",
|
|
||||||
"phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically"
|
|
||||||
},
|
|
||||||
"type": "phpstan-extension",
|
|
||||||
"extra": {
|
|
||||||
"phpstan": {
|
|
||||||
"includes": [
|
|
||||||
"extension.neon"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-master": "3.0-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Larastan\\Larastan\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Can Vural",
|
|
||||||
"email": "can9119@gmail.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel",
|
|
||||||
"keywords": [
|
|
||||||
"PHPStan",
|
|
||||||
"code analyse",
|
|
||||||
"code analysis",
|
|
||||||
"larastan",
|
|
||||||
"laravel",
|
|
||||||
"package",
|
|
||||||
"php",
|
|
||||||
"static analysis"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/larastan/larastan/issues",
|
|
||||||
"source": "https://github.com/larastan/larastan/tree/v3.9.6"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://github.com/canvural",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-04-16T10:02:43+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "laravel/pail",
|
"name": "laravel/pail",
|
||||||
"version": "v1.2.6",
|
"version": "v1.2.6",
|
||||||
|
|
@ -7056,59 +6992,6 @@
|
||||||
},
|
},
|
||||||
"time": "2022-02-21T01:04:05+00:00"
|
"time": "2022-02-21T01:04:05+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "phpstan/phpstan",
|
|
||||||
"version": "2.1.56",
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/93a603c9fc3be8c3c93bbc8d22170ad766685537",
|
|
||||||
"reference": "93a603c9fc3be8c3c93bbc8d22170ad766685537",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^7.4|^8.0"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"phpstan/phpstan-shim": "*"
|
|
||||||
},
|
|
||||||
"bin": [
|
|
||||||
"phpstan",
|
|
||||||
"phpstan.phar"
|
|
||||||
],
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"files": [
|
|
||||||
"bootstrap.php"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"description": "PHPStan - PHP Static Analysis Tool",
|
|
||||||
"keywords": [
|
|
||||||
"dev",
|
|
||||||
"static analysis"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"docs": "https://phpstan.org/user-guide/getting-started",
|
|
||||||
"forum": "https://github.com/phpstan/phpstan/discussions",
|
|
||||||
"issues": "https://github.com/phpstan/phpstan/issues",
|
|
||||||
"security": "https://github.com/phpstan/phpstan/security/policy",
|
|
||||||
"source": "https://github.com/phpstan/phpstan-src"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://github.com/ondrejmirtes",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/phpstan",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-05-26T17:04:57+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "phpunit/php-code-coverage",
|
"name": "phpunit/php-code-coverage",
|
||||||
"version": "12.5.6",
|
"version": "12.5.6",
|
||||||
|
|
|
||||||
117
config/auth.php
Normal file
117
config/auth.php
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Defaults
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default authentication "guard" and password
|
||||||
|
| reset "broker" for your application. You may change these values
|
||||||
|
| as required, but they're a perfect start for most applications.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'defaults' => [
|
||||||
|
'guard' => env('AUTH_GUARD', 'web'),
|
||||||
|
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Next, you may define every authentication guard for your application.
|
||||||
|
| Of course, a great default configuration has been defined for you
|
||||||
|
| which utilizes session storage plus the Eloquent user provider.
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| Supported: "session"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guards' => [
|
||||||
|
'web' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'users',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| User Providers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| If you have multiple user tables or models you may configure multiple
|
||||||
|
| providers to represent the model / table. These providers may then
|
||||||
|
| be assigned to any extra authentication guards you have defined.
|
||||||
|
|
|
||||||
|
| Supported: "database", "eloquent"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'providers' => [
|
||||||
|
'users' => [
|
||||||
|
'driver' => 'eloquent',
|
||||||
|
'model' => env('AUTH_MODEL', User::class),
|
||||||
|
],
|
||||||
|
|
||||||
|
// 'users' => [
|
||||||
|
// 'driver' => 'database',
|
||||||
|
// 'table' => 'users',
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Resetting Passwords
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options specify the behavior of Laravel's password
|
||||||
|
| reset functionality, including the table utilized for token storage
|
||||||
|
| and the user provider that is invoked to actually retrieve users.
|
||||||
|
|
|
||||||
|
| The expiry time is the number of minutes that each reset token will be
|
||||||
|
| considered valid. This security feature keeps tokens short-lived so
|
||||||
|
| they have less time to be guessed. You may change this as needed.
|
||||||
|
|
|
||||||
|
| The throttle setting is the number of seconds a user must wait before
|
||||||
|
| generating more password reset tokens. This prevents the user from
|
||||||
|
| quickly generating a very large amount of password reset tokens.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'passwords' => [
|
||||||
|
'users' => [
|
||||||
|
'provider' => 'users',
|
||||||
|
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||||
|
'expire' => 60,
|
||||||
|
'throttle' => 60,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Password Confirmation Timeout
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define the amount of seconds before a password confirmation
|
||||||
|
| window expires and users are asked to re-enter their password via the
|
||||||
|
| confirmation screen. By default, the timeout lasts for three hours.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||||
|
|
||||||
|
];
|
||||||
55
config/inertia.php
Normal file
55
config/inertia.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Server Side Rendering
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These options configures if and how Inertia uses Server Side Rendering
|
||||||
|
| to pre-render each initial request made to your application's pages
|
||||||
|
| so that server rendered HTML is delivered for the user's browser.
|
||||||
|
|
|
||||||
|
| See: https://inertiajs.com/server-side-rendering
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'ssr' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'url' => 'http://127.0.0.1:13714',
|
||||||
|
// 'bundle' => base_path('bootstrap/ssr/ssr.mjs'),
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Testing
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The values described here are used to locate Inertia components on the
|
||||||
|
| filesystem. For instance, when using `assertInertia`, the assertion
|
||||||
|
| attempts to locate the component as a file relative to the paths.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'testing' => [
|
||||||
|
|
||||||
|
'ensure_pages_exist' => true,
|
||||||
|
|
||||||
|
'page_paths' => [
|
||||||
|
resource_path('js/pages'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'page_extensions' => [
|
||||||
|
'js',
|
||||||
|
'jsx',
|
||||||
|
'svelte',
|
||||||
|
'ts',
|
||||||
|
'tsx',
|
||||||
|
'vue',
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'driver' => env('SESSION_DRIVER', 'cookie'),
|
'driver' => env('SESSION_DRIVER', 'database'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use App\Models\Tracker;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @extends Factory<Tracker>
|
|
||||||
*/
|
|
||||||
class TrackerFactory extends Factory
|
|
||||||
{
|
|
||||||
protected $model = Tracker::class;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function definition(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'label' => 'Counter',
|
|
||||||
'unit' => 'units',
|
|
||||||
'count' => 0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
45
database/factories/UserFactory.php
Normal file
45
database/factories/UserFactory.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<User>
|
||||||
|
*/
|
||||||
|
class UserFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The current password being used by the factory.
|
||||||
|
*/
|
||||||
|
protected static ?string $password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => fake()->name(),
|
||||||
|
'email' => fake()->unique()->safeEmail(),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
|
'remember_token' => Str::random(10),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the model's email address should be unverified.
|
||||||
|
*/
|
||||||
|
public function unverified(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'email_verified_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,26 +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::dropIfExists('milestones');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::create('milestones', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('tracker_id')->constrained()->cascadeOnDelete();
|
|
||||||
$table->integer('target');
|
|
||||||
$table->string('description');
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
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('trackers', function (Blueprint $table): void {
|
|
||||||
$table->unsignedInteger('count')->default(0)->after('unit');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Schema::hasTable('entries')) {
|
|
||||||
DB::table('trackers')->orderBy('id')->each(function (object $tracker): void {
|
|
||||||
$total = (float) DB::table('entries')->where('tracker_id', $tracker->id)->sum('quantity');
|
|
||||||
|
|
||||||
DB::table('trackers')
|
|
||||||
->where('id', $tracker->id)
|
|
||||||
->update(['count' => max(0, (int) round($total))]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Schema::dropIfExists('entries');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lossy by design: per-entry dates and prices cannot be rebuilt from a scalar.
|
|
||||||
public function down(): 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']);
|
|
||||||
});
|
|
||||||
|
|
||||||
DB::table('trackers')->where('count', '>', 0)->orderBy('id')->each(function (object $tracker): void {
|
|
||||||
DB::table('entries')->insert([
|
|
||||||
'tracker_id' => $tracker->id,
|
|
||||||
'date' => now()->toDateString(),
|
|
||||||
'quantity' => $tracker->count,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::table('trackers', function (Blueprint $table): void {
|
|
||||||
$table->dropColumn('count');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,51 +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::table('trackers', function (Blueprint $table): void {
|
|
||||||
$table->dropForeign(['asset_id']);
|
|
||||||
$table->dropColumn(['asset_id', 'price_tracking_enabled']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::dropIfExists('asset_prices');
|
|
||||||
Schema::dropIfExists('assets');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lossy by design: recorded symbols and prices cannot be recovered.
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::create('assets', function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->string('symbol')->unique();
|
|
||||||
$table->string('full_name')->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index('symbol');
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('asset_prices', function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('asset_id')->constrained()->onDelete('cascade');
|
|
||||||
$table->date('date');
|
|
||||||
$table->decimal('price', 10, 4);
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->unique(['asset_id', 'date']);
|
|
||||||
$table->index('asset_id');
|
|
||||||
$table->index('date');
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::table('trackers', function (Blueprint $table): void {
|
|
||||||
$table->foreignId('asset_id')->nullable()->constrained()->nullOnDelete();
|
|
||||||
$table->boolean('price_tracking_enabled')->default(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,57 +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::table('trackers', function (Blueprint $table): void {
|
|
||||||
$table->dropForeign(['user_id']);
|
|
||||||
$table->dropColumn('user_id');
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::dropIfExists('sessions');
|
|
||||||
Schema::dropIfExists('password_reset_tokens');
|
|
||||||
Schema::dropIfExists('users');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lossy by design: the app has no users, so nothing is restored into these tables.
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::create('users', function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->string('name');
|
|
||||||
$table->string('email')->unique();
|
|
||||||
$table->timestamp('email_verified_at')->nullable();
|
|
||||||
$table->string('password');
|
|
||||||
$table->rememberToken();
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('password_reset_tokens', function (Blueprint $table): void {
|
|
||||||
$table->string('email')->primary();
|
|
||||||
$table->string('token');
|
|
||||||
$table->timestamp('created_at')->nullable();
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('sessions', function (Blueprint $table): void {
|
|
||||||
$table->string('id')->primary();
|
|
||||||
$table->foreignId('user_id')->nullable()->index();
|
|
||||||
$table->string('ip_address', 45)->nullable();
|
|
||||||
$table->text('user_agent')->nullable();
|
|
||||||
$table->longText('payload');
|
|
||||||
$table->integer('last_activity')->index();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Nullable, unlike the original: existing trackers have no user to point at,
|
|
||||||
// so a NOT NULL foreign key cannot be added back.
|
|
||||||
Schema::table('trackers', function (Blueprint $table): void {
|
|
||||||
$table->foreignId('user_id')->nullable()->after('id')->constrained()->cascadeOnDelete();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Models\Tracker;
|
use App\Models\User;
|
||||||
|
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
class DatabaseSeeder extends Seeder
|
class DatabaseSeeder extends Seeder
|
||||||
|
|
@ -12,10 +13,11 @@ class DatabaseSeeder extends Seeder
|
||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
Tracker::firstOrCreate([], [
|
// User::factory(10)->create();
|
||||||
'label' => 'Counter',
|
|
||||||
'unit' => 'units',
|
User::factory()->create([
|
||||||
'count' => 0,
|
'name' => 'Test User',
|
||||||
|
'email' => 'test@example.com',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
# Image for CI: PHP, Composer and the extensions the suite needs. No runtime
|
|
||||||
# server; the frontend build runs in its own job on a node image.
|
|
||||||
#
|
|
||||||
# Published as incr-ci:php<version>-<revision>, not :latest. Runners cache
|
|
||||||
# mutable tags and will not re-pull them, so bump the revision in the tag and
|
|
||||||
# in ci.yml whenever this file changes.
|
|
||||||
#
|
|
||||||
# Debian-based rather than alpine: ffr hit repeated DNS resolution timeouts
|
|
||||||
# against codeload.github.com on the alpine build.
|
|
||||||
#
|
|
||||||
# pdo_mysql, not sqlite: the migration tests query information_schema and
|
|
||||||
# exercise MySQL-specific DDL, so CI runs against a real mysql:8.0 service.
|
|
||||||
FROM php:8.3-cli
|
|
||||||
|
|
||||||
COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
|
|
||||||
|
|
||||||
RUN install-php-extensions \
|
|
||||||
pdo_mysql \
|
|
||||||
mbstring \
|
|
||||||
dom \
|
|
||||||
xml \
|
|
||||||
bcmath \
|
|
||||||
fileinfo \
|
|
||||||
pcntl \
|
|
||||||
gd \
|
|
||||||
pcov
|
|
||||||
|
|
||||||
# netcat-openbsd backs the MySQL readiness check in ci.yml.
|
|
||||||
# nodejs is not used by the app's tests; the Forgejo JavaScript actions
|
|
||||||
# (checkout, cache) are executed with it inside this container.
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends git unzip nodejs netcat-openbsd \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Multi-stage build for Laravel + Livewire application
|
# Multi-stage build for Laravel + React application
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
@ -13,6 +13,9 @@ RUN npm ci
|
||||||
COPY resources/ resources/
|
COPY resources/ resources/
|
||||||
COPY public/ public/
|
COPY public/ public/
|
||||||
COPY vite.config.ts ./
|
COPY vite.config.ts ./
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY components.json ./
|
||||||
|
COPY eslint.config.js ./
|
||||||
|
|
||||||
# Build frontend assets
|
# Build frontend assets
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
|
||||||
44
eslint.config.js
Normal file
44
eslint.config.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import react from 'eslint-plugin-react';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import globals from 'globals';
|
||||||
|
import typescript from 'typescript-eslint';
|
||||||
|
|
||||||
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
|
export default [
|
||||||
|
js.configs.recommended,
|
||||||
|
...typescript.configs.recommended,
|
||||||
|
{
|
||||||
|
...react.configs.flat.recommended,
|
||||||
|
...react.configs.flat['jsx-runtime'], // Required for React 17+
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
'react/no-unescaped-entities': 'off',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'],
|
||||||
|
},
|
||||||
|
prettier, // Turn off all rules that might conflict with Prettier
|
||||||
|
];
|
||||||
6303
package-lock.json
generated
6303
package-lock.json
generated
File diff suppressed because it is too large
Load diff
47
package.json
47
package.json
|
|
@ -3,12 +3,57 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev": "vite"
|
"build:ssr": "vite build && vite build --ssr",
|
||||||
|
"dev": "vite",
|
||||||
|
"format": "prettier --write resources/",
|
||||||
|
"format:check": "prettier --check resources/",
|
||||||
|
"lint": "eslint . --fix",
|
||||||
|
"types": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.19.0",
|
||||||
|
"@types/node": "^22.13.5",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-react": "^7.37.3",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prettier-plugin-organize-imports": "^4.1.0",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
"typescript-eslint": "^8.23.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.2.0",
|
||||||
|
"@inertiajs/react": "^3.0.3",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.3",
|
||||||
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||||
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.5",
|
||||||
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.2",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
|
"@types/react": "^19.0.3",
|
||||||
|
"@types/react-dom": "^19.0.2",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"concurrently": "^9.0.1",
|
||||||
|
"globals": "^15.14.0",
|
||||||
"laravel-vite-plugin": "^3.1.0",
|
"laravel-vite-plugin": "^3.1.0",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.0.1",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.0.10"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|
|
||||||
21
phpstan.neon
21
phpstan.neon
|
|
@ -1,21 +0,0 @@
|
||||||
includes:
|
|
||||||
- vendor/larastan/larastan/extension.neon
|
|
||||||
|
|
||||||
parameters:
|
|
||||||
level: 7
|
|
||||||
paths:
|
|
||||||
- app/
|
|
||||||
- database/
|
|
||||||
- tests/
|
|
||||||
|
|
||||||
excludePaths:
|
|
||||||
- bootstrap/*.php
|
|
||||||
- storage/*
|
|
||||||
|
|
||||||
ignoreErrors:
|
|
||||||
# Migration files return an anonymous class; the base Migration declares neither up() nor down().
|
|
||||||
# An interface on the migration is not an option: it would live under autoload-dev, which
|
|
||||||
# production omits (`composer install --no-dev`), so every migration would fatal on deploy.
|
|
||||||
-
|
|
||||||
message: '#Call to an undefined method Illuminate\\Database\\Migrations\\Migration::(up|down)\(\)#'
|
|
||||||
path: tests/Feature/*MigrationTest.php
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@plugin 'tailwindcss-animate';
|
||||||
|
|
||||||
@source '../views';
|
@source '../views';
|
||||||
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: '7Segment';
|
font-family: '7Segment';
|
||||||
|
|
@ -21,3 +24,159 @@ .glow-red {
|
||||||
.glow-red:hover {
|
.glow-red:hover {
|
||||||
box-shadow: 0 0 25px rgba(239, 68, 68, 0.6);
|
box-shadow: 0 0 25px rgba(239, 68, 68, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans:
|
||||||
|
'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
|
||||||
|
--font-mono-display:
|
||||||
|
'Major Mono Display', ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
|
||||||
|
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.87 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.87 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.145 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.145 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.985 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.396 0.141 25.723);
|
||||||
|
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||||
|
--border: oklch(0.269 0 0);
|
||||||
|
--input: oklch(0.269 0 0);
|
||||||
|
--ring: oklch(0.439 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(0.269 0 0);
|
||||||
|
--sidebar-ring: oklch(0.439 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
24
resources/js/app.tsx
Normal file
24
resources/js/app.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import '../css/app.css';
|
||||||
|
|
||||||
|
import { createInertiaApp } from '@inertiajs/react';
|
||||||
|
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { initializeTheme } from './hooks/use-appearance';
|
||||||
|
|
||||||
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
title: (title) => title ? `${title} - ${appName}` : appName,
|
||||||
|
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
|
||||||
|
setup({ el, App, props }) {
|
||||||
|
const root = createRoot(el);
|
||||||
|
|
||||||
|
root.render(<App {...props} />);
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
color: '#4B5563',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// This will set light / dark mode on load...
|
||||||
|
initializeTheme();
|
||||||
55
resources/js/components/Display/InlineForm.tsx
Normal file
55
resources/js/components/Display/InlineForm.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import AddEntryForm from '@/components/Transactions/AddEntryForm';
|
||||||
|
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type FormType = 'purchase' | 'milestone';
|
||||||
|
|
||||||
|
interface InlineFormProps {
|
||||||
|
type: FormType | null;
|
||||||
|
unit?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess?: (type: FormType) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InlineForm({
|
||||||
|
type,
|
||||||
|
unit = 'units',
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
className,
|
||||||
|
}: InlineFormProps) {
|
||||||
|
if (!type) return null;
|
||||||
|
|
||||||
|
const handleSuccess = () => {
|
||||||
|
if (onSuccess) onSuccess(type);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-black p-8',
|
||||||
|
'transition-all duration-300',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{type === 'purchase' ? (
|
||||||
|
<AddEntryForm
|
||||||
|
unit={unit}
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
onCancel={onClose}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AddMilestoneForm
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
onCancel={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
resources/js/components/Display/LedDisplay.tsx
Normal file
53
resources/js/components/Display/LedDisplay.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface LedDisplayProps {
|
||||||
|
value: number;
|
||||||
|
className?: string;
|
||||||
|
animate?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LedDisplay({
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
onClick
|
||||||
|
}: LedDisplayProps) {
|
||||||
|
const [displayValue, setDisplayValue] = useState(0);
|
||||||
|
|
||||||
|
// Animate number changes
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayValue(value);
|
||||||
|
return;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const formattedValue = Math.floor(displayValue).toString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full text-center select-none cursor-pointer",
|
||||||
|
"bg-black text-red-500",
|
||||||
|
"px-8 py-12 transition-all duration-300",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="relative w-full flex items-center justify-center">
|
||||||
|
<div className={cn(
|
||||||
|
"relative z-10",
|
||||||
|
"text-[8rem] md:text-[12rem] lg:text-[16rem]",
|
||||||
|
"font-digital font-normal",
|
||||||
|
"text-red-500",
|
||||||
|
"drop-shadow-[0_0_10px_rgba(239,68,68,0.8)]",
|
||||||
|
"filter brightness-110",
|
||||||
|
"leading-none",
|
||||||
|
"transition-all duration-300"
|
||||||
|
)}
|
||||||
|
style={{ letterSpacing: '0.15em' }}>
|
||||||
|
{formattedValue}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
resources/js/components/Display/ProgressBar.tsx
Normal file
62
resources/js/components/Display/ProgressBar.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { Milestone } from '@/types/domain';
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
currentQuantity: number;
|
||||||
|
milestones: Milestone[];
|
||||||
|
selectedMilestoneIndex?: number;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProgressBar({
|
||||||
|
currentQuantity,
|
||||||
|
milestones,
|
||||||
|
selectedMilestoneIndex = 0,
|
||||||
|
className,
|
||||||
|
onClick
|
||||||
|
}: ProgressBarProps) {
|
||||||
|
const selectedMilestone = milestones.length > 0 && selectedMilestoneIndex < milestones.length
|
||||||
|
? milestones[selectedMilestoneIndex]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const progressPercentage = selectedMilestone
|
||||||
|
? Math.min((currentQuantity / selectedMilestone.target) * 100, 100)
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-black cursor-pointer",
|
||||||
|
"transition-all duration-300",
|
||||||
|
"p-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* Progress Bar Container */}
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Old-school progress bar with overlaid text */}
|
||||||
|
<div className="w-full border-4 border-red-500 p-2 bg-black relative overflow-hidden glow-red">
|
||||||
|
{/* Inner container */}
|
||||||
|
<div className="relative h-8">
|
||||||
|
{/* Progress fill */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 h-full bg-red-500 transition-all duration-500 ease-out"
|
||||||
|
style={{ width: `${progressPercentage}%` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Text overlay */}
|
||||||
|
{selectedMilestone && (
|
||||||
|
<div className="relative h-full flex items-center justify-center">
|
||||||
|
{/* Base text (red on black background) */}
|
||||||
|
<div className="text-red-500 font-mono text-sm font-bold mix-blend-difference relative z-10">
|
||||||
|
{progressPercentage.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
resources/js/components/Display/StatsBox.tsx
Normal file
143
resources/js/components/Display/StatsBox.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Plus, ChevronRight } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import ComponentTitle from '@/components/ui/ComponentTitle';
|
||||||
|
import type { Milestone } from '@/types/domain';
|
||||||
|
|
||||||
|
interface StatsBoxProps {
|
||||||
|
stats: {
|
||||||
|
totalShares: number;
|
||||||
|
};
|
||||||
|
unit?: string;
|
||||||
|
milestones?: Milestone[];
|
||||||
|
selectedMilestoneIndex?: number;
|
||||||
|
onMilestoneSelect?: (index: number) => void;
|
||||||
|
className?: string;
|
||||||
|
onAddPurchase?: () => void;
|
||||||
|
onAddMilestone?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatsBox({
|
||||||
|
stats,
|
||||||
|
unit = 'units',
|
||||||
|
milestones = [],
|
||||||
|
selectedMilestoneIndex = 0,
|
||||||
|
onMilestoneSelect,
|
||||||
|
className,
|
||||||
|
onAddPurchase,
|
||||||
|
onAddMilestone,
|
||||||
|
}: StatsBoxProps) {
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleCycleMilestone = () => {
|
||||||
|
if (milestones.length === 0 || !onMilestoneSelect) return;
|
||||||
|
const nextIndex = (selectedMilestoneIndex + 1) % milestones.length;
|
||||||
|
onMilestoneSelect(nextIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-black p-8",
|
||||||
|
"transition-all duration-300",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
|
||||||
|
<div className="flex justify-between items-center mb-6 relative">
|
||||||
|
<ComponentTitle>Stats</ComponentTitle>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 relative">
|
||||||
|
{/* Action Dropdown */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
|
className="flex items-center justify-center px-2 py-1 rounded border border-red-500/50 text-red-500 hover:bg-red-800/40 hover:text-red-300 transition-colors text-sm"
|
||||||
|
aria-label="Add actions"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="absolute top-full right-0 mt-2 bg-black border-2 border-red-500/50 rounded shadow-lg min-w-40 z-10">
|
||||||
|
{onAddPurchase && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onAddPurchase();
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
||||||
|
>
|
||||||
|
ADD ENTRY
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onAddMilestone && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onAddMilestone();
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
||||||
|
>
|
||||||
|
ADD MILESTONE
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestone Cycle Button */}
|
||||||
|
{milestones.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={handleCycleMilestone}
|
||||||
|
className="flex items-center justify-center px-2 py-1 rounded border border-red-500/50 text-red-500 hover:bg-red-800/40 hover:text-red-300 transition-colors text-sm"
|
||||||
|
aria-label="Cycle milestone"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestone Table */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<div className="text-red-500 underline font-bold mb-3 font-mono">MILESTONES</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm font-mono">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-red-500 text-xs py-2">DESCRIPTION</th>
|
||||||
|
<th className="text-right text-red-500 text-xs py-2">{unit.toUpperCase()}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="text-red-500 font-bold">
|
||||||
|
<td className="py-1 pr-4">CURRENT</td>
|
||||||
|
<td className="text-right py-1">
|
||||||
|
{Math.floor(stats.totalShares).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{milestones.map((milestone, index) => (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
index === selectedMilestoneIndex
|
||||||
|
? "bg-red-500 text-black"
|
||||||
|
: "text-red-500 font-bold"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="py-1 pr-4">{milestone.description}</td>
|
||||||
|
<td className="text-right py-1">
|
||||||
|
{Math.floor(milestone.target).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
resources/js/components/InputError.tsx
Normal file
10
resources/js/components/InputError.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { type HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
export default function InputError({ message, className = '', ...props }: HTMLAttributes<HTMLParagraphElement> & { message?: string }) {
|
||||||
|
return message ? (
|
||||||
|
<p {...props} className={cn('text-sm text-red-600 dark:text-red-400', className)}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
97
resources/js/components/Milestones/AddMilestoneForm.tsx
Normal file
97
resources/js/components/Milestones/AddMilestoneForm.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import InputError from '@/components/InputError';
|
||||||
|
import { useForm } from '@inertiajs/react';
|
||||||
|
import { LoaderCircle } from 'lucide-react';
|
||||||
|
import { FormEventHandler } from 'react';
|
||||||
|
import ComponentTitle from '@/components/ui/ComponentTitle';
|
||||||
|
|
||||||
|
interface MilestoneFormData {
|
||||||
|
target: string;
|
||||||
|
description: string;
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddMilestoneFormProps {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddMilestoneForm({ onSuccess, onCancel }: AddMilestoneFormProps) {
|
||||||
|
const { data, setData, post, processing, errors, reset } = useForm<MilestoneFormData>({
|
||||||
|
target: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit: FormEventHandler = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
post(route('milestones.store'), {
|
||||||
|
onSuccess: () => {
|
||||||
|
reset();
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ComponentTitle>ADD MILESTONE</ComponentTitle>
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="target" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Target Number</Label>
|
||||||
|
<Input
|
||||||
|
id="target"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="1"
|
||||||
|
placeholder="1500"
|
||||||
|
value={data.target}
|
||||||
|
onChange={(e) => setData('target', e.target.value)}
|
||||||
|
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
|
||||||
|
/>
|
||||||
|
<InputError message={errors.target} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Description</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
type="text"
|
||||||
|
placeholder="First milestone"
|
||||||
|
value={data.description}
|
||||||
|
onChange={(e) => setData('description', e.target.value)}
|
||||||
|
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
|
||||||
|
/>
|
||||||
|
<InputError message={errors.description} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={processing}
|
||||||
|
className="flex-1 bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
||||||
|
>
|
||||||
|
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
[EXECUTE]
|
||||||
|
</Button>
|
||||||
|
{onCancel && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
||||||
|
>
|
||||||
|
[ABORT]
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
resources/js/components/Onboarding/CreateTrackerStep.tsx
Normal file
113
resources/js/components/Onboarding/CreateTrackerStep.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
resources/js/components/Onboarding/OnboardingFlow.tsx
Normal file
104
resources/js/components/Onboarding/OnboardingFlow.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import InputError from '@/components/InputError';
|
||||||
|
import { todayISO } from '@/lib/utils';
|
||||||
|
import { LoaderCircle } from 'lucide-react';
|
||||||
|
import { FormEventHandler, useState } from 'react';
|
||||||
|
|
||||||
|
interface OnboardingFlowProps {
|
||||||
|
onComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrfToken(): string {
|
||||||
|
return (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
||||||
|
const [startingValue, setStartingValue] = useState('0');
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const submit: FormEventHandler = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setProcessing(true);
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': csrfToken(),
|
||||||
|
Accept: 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const trackerResponse = await fetch(route('tracker.store'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!trackerResponse.ok && trackerResponse.status !== 409) {
|
||||||
|
setError('Could not create the counter. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = Number(startingValue);
|
||||||
|
|
||||||
|
if (quantity > 0) {
|
||||||
|
const entryResponse = await fetch(route('entries.store'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ date: todayISO(), quantity }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!entryResponse.ok) {
|
||||||
|
setError('Could not save the starting value. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete?.();
|
||||||
|
} catch {
|
||||||
|
setError('Something went wrong. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="border-2 border-red-500 bg-black shadow-[0_0_20px_rgba(239,68,68,0.3)] p-8">
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<Label
|
||||||
|
htmlFor="starting-value"
|
||||||
|
className="text-red-400 font-mono text-xs uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
> Starting Value
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="starting-value"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
autoFocus
|
||||||
|
value={startingValue}
|
||||||
|
onChange={(e) => setStartingValue(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={error} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={processing || startingValue === ''}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
resources/js/components/Transactions/AddEntryForm.tsx
Normal file
127
resources/js/components/Transactions/AddEntryForm.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
resources/js/components/ui/ComponentTitle.tsx
Normal file
15
resources/js/components/ui/ComponentTitle.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { FC, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface ComponentTitleProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ComponentTitle: FC<ComponentTitleProps> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<h2 className="text-red-500 text-lg font-mono font-bold tracking-wider uppercase">
|
||||||
|
{ children }
|
||||||
|
</h2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ComponentTitle
|
||||||
57
resources/js/components/ui/TerminalSpinner.tsx
Normal file
57
resources/js/components/ui/TerminalSpinner.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface TerminalSpinnerProps {
|
||||||
|
text?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
fullScreen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TerminalSpinner({
|
||||||
|
text = 'LOADING',
|
||||||
|
size = 'md',
|
||||||
|
fullScreen = false
|
||||||
|
}: TerminalSpinnerProps) {
|
||||||
|
const [dots, setDots] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setDots(prev => (prev + 1) % 4);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getDots = () => {
|
||||||
|
switch (dots) {
|
||||||
|
case 0: return ' ';
|
||||||
|
case 1: return '. ';
|
||||||
|
case 2: return '.. ';
|
||||||
|
case 3: return '...';
|
||||||
|
default: return ' ';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'text-sm',
|
||||||
|
md: 'text-lg',
|
||||||
|
lg: 'text-xl'
|
||||||
|
};
|
||||||
|
|
||||||
|
const spinner = (
|
||||||
|
<div className="border border-red-500/30 bg-black p-6">
|
||||||
|
<span className={`text-red-500 font-mono ${sizeClasses[size]} uppercase tracking-wider`}>
|
||||||
|
[SYSTEM] {text}<span className="inline-block w-8">{getDots()}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fullScreen) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||||
|
{spinner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return spinner;
|
||||||
|
}
|
||||||
58
resources/js/components/ui/button.tsx
Normal file
58
resources/js/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
21
resources/js/components/ui/input.tsx
Normal file
21
resources/js/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
22
resources/js/components/ui/label.tsx
Normal file
22
resources/js/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
73
resources/js/hooks/use-appearance.tsx
Normal file
73
resources/js/hooks/use-appearance.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export type Appearance = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
const prefersDark = () => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCookie = (name: string, value: string, days = 365) => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAge = days * 24 * 60 * 60;
|
||||||
|
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTheme = (appearance: Appearance) => {
|
||||||
|
const isDark = appearance === 'dark' || (appearance === 'system' && prefersDark());
|
||||||
|
|
||||||
|
document.documentElement.classList.toggle('dark', isDark);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mediaQuery = () => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSystemThemeChange = () => {
|
||||||
|
const currentAppearance = localStorage.getItem('appearance') as Appearance;
|
||||||
|
applyTheme(currentAppearance || 'system');
|
||||||
|
};
|
||||||
|
|
||||||
|
export function initializeTheme() {
|
||||||
|
const savedAppearance = (localStorage.getItem('appearance') as Appearance) || 'system';
|
||||||
|
|
||||||
|
applyTheme(savedAppearance);
|
||||||
|
|
||||||
|
// Add the event listener for system theme changes...
|
||||||
|
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppearance() {
|
||||||
|
const [appearance, setAppearance] = useState<Appearance>('system');
|
||||||
|
|
||||||
|
const updateAppearance = useCallback((mode: Appearance) => {
|
||||||
|
setAppearance(mode);
|
||||||
|
|
||||||
|
// Store in localStorage for client-side persistence...
|
||||||
|
localStorage.setItem('appearance', mode);
|
||||||
|
|
||||||
|
// Store in cookie for SSR...
|
||||||
|
setCookie('appearance', mode);
|
||||||
|
|
||||||
|
applyTheme(mode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedAppearance = localStorage.getItem('appearance') as Appearance | null;
|
||||||
|
updateAppearance(savedAppearance || 'system');
|
||||||
|
|
||||||
|
return () => mediaQuery()?.removeEventListener('change', handleSystemThemeChange);
|
||||||
|
}, [updateAppearance]);
|
||||||
|
|
||||||
|
return { appearance, updateAppearance } as const;
|
||||||
|
}
|
||||||
8
resources/js/lib/utils.ts
Normal file
8
resources/js/lib/utils.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const todayISO = (): string => new Date().toISOString().split('T')[0];
|
||||||
174
resources/js/pages/dashboard.tsx
Normal file
174
resources/js/pages/dashboard.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import LedDisplay from '@/components/Display/LedDisplay';
|
||||||
|
import InlineForm from '@/components/Display/InlineForm';
|
||||||
|
import ProgressBar from '@/components/Display/ProgressBar';
|
||||||
|
import StatsBox from '@/components/Display/StatsBox';
|
||||||
|
import OnboardingFlow from '@/components/Onboarding/OnboardingFlow';
|
||||||
|
import TerminalSpinner from '@/components/ui/TerminalSpinner';
|
||||||
|
import { Head } from '@inertiajs/react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { Milestone, Tracker } from '@/types/domain';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [totalShares, setTotalShares] = useState(0);
|
||||||
|
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||||
|
const [selectedMilestoneIndex, setSelectedMilestoneIndex] = useState(0);
|
||||||
|
const [showProgressBar, setShowProgressBar] = useState(false);
|
||||||
|
const [showStatsBox, setShowStatsBox] = useState(false);
|
||||||
|
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||||
|
const [tracker, setTracker] = useState<Tracker | null>(null);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
const [entriesResponse, milestonesResponse, trackerResponse] = await Promise.all([
|
||||||
|
fetch('/entries/summary'),
|
||||||
|
fetch('/milestones'),
|
||||||
|
fetch('/tracker'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (entriesResponse.ok) {
|
||||||
|
const entries = await entriesResponse.json();
|
||||||
|
setTotalShares(entries.total_quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (milestonesResponse.ok) {
|
||||||
|
setMilestones(await milestonesResponse.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
let trackerExists = false;
|
||||||
|
|
||||||
|
if (trackerResponse.ok) {
|
||||||
|
const { exists, tracker: trackerData } = await trackerResponse.json();
|
||||||
|
setTracker(trackerData ?? null);
|
||||||
|
trackerExists = Boolean(exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
setNeedsOnboarding(!trackerExists);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handlePurchaseSuccess = async () => {
|
||||||
|
try {
|
||||||
|
const entriesResponse = await fetch('/entries/summary');
|
||||||
|
if (entriesResponse.ok) {
|
||||||
|
const entries = await entriesResponse.json();
|
||||||
|
setTotalShares(entries.total_quantity);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh entry data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMilestoneSuccess = async () => {
|
||||||
|
try {
|
||||||
|
const milestonesResponse = await fetch('/milestones');
|
||||||
|
if (milestonesResponse.ok) {
|
||||||
|
const milestonesData = await milestonesResponse.json();
|
||||||
|
setMilestones(milestonesData);
|
||||||
|
setSelectedMilestoneIndex(0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh milestone data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMilestoneSelect = (index: number) => {
|
||||||
|
setSelectedMilestoneIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnboardingComplete = loadData;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Dashboard" />
|
||||||
|
<TerminalSpinner fullScreen />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLedClick = () => {
|
||||||
|
const newShowProgressBar = !showProgressBar;
|
||||||
|
setShowProgressBar(newShowProgressBar);
|
||||||
|
if (!newShowProgressBar) {
|
||||||
|
setShowStatsBox(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgressClick = () => {
|
||||||
|
setShowStatsBox(!showStatsBox);
|
||||||
|
setActiveForm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (needsOnboarding) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Asset Tracker - Setup" />
|
||||||
|
<OnboardingFlow onComplete={handleOnboardingComplete} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="incr" />
|
||||||
|
|
||||||
|
<div className="min-h-screen bg-black">
|
||||||
|
<div className="w-full max-w-4xl mx-auto px-4">
|
||||||
|
<div className="pt-32">
|
||||||
|
<LedDisplay
|
||||||
|
value={totalShares}
|
||||||
|
onClick={handleLedClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: showProgressBar ? 'block' : 'none' }}>
|
||||||
|
<ProgressBar
|
||||||
|
currentQuantity={totalShares}
|
||||||
|
milestones={milestones}
|
||||||
|
selectedMilestoneIndex={selectedMilestoneIndex}
|
||||||
|
onClick={handleProgressClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: showStatsBox ? 'block' : 'none' }}>
|
||||||
|
<StatsBox
|
||||||
|
stats={{ totalShares }}
|
||||||
|
unit={tracker?.unit}
|
||||||
|
milestones={milestones}
|
||||||
|
selectedMilestoneIndex={selectedMilestoneIndex}
|
||||||
|
onMilestoneSelect={handleMilestoneSelect}
|
||||||
|
onAddPurchase={() => setActiveForm('purchase')}
|
||||||
|
onAddMilestone={() => setActiveForm('milestone')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: activeForm && showProgressBar && showStatsBox ? 'block' : 'none' }}>
|
||||||
|
<InlineForm
|
||||||
|
type={activeForm}
|
||||||
|
unit={tracker?.unit}
|
||||||
|
onClose={() => setActiveForm(null)}
|
||||||
|
onSuccess={(type) => {
|
||||||
|
if (type === 'purchase') handlePurchaseSuccess();
|
||||||
|
else if (type === 'milestone') handleMilestoneSuccess();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
resources/js/ssr.tsx
Normal file
30
resources/js/ssr.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { createInertiaApp } from '@inertiajs/react';
|
||||||
|
import createServer from '@inertiajs/react/server';
|
||||||
|
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||||
|
import ReactDOMServer from 'react-dom/server';
|
||||||
|
import { type RouteName, route } from 'ziggy-js';
|
||||||
|
|
||||||
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
|
|
||||||
|
createServer((page) =>
|
||||||
|
createInertiaApp({
|
||||||
|
page,
|
||||||
|
render: ReactDOMServer.renderToString,
|
||||||
|
title: (title) => title ? `${title} - ${appName}` : appName,
|
||||||
|
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
|
||||||
|
setup: ({ App, props }) => {
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-expect-error
|
||||||
|
global.route<RouteName> = (name, params, absolute) =>
|
||||||
|
route(name, params as any, absolute, {
|
||||||
|
// @ts-expect-error
|
||||||
|
...page.props.ziggy,
|
||||||
|
// @ts-expect-error
|
||||||
|
location: new URL(page.props.ziggy.location),
|
||||||
|
});
|
||||||
|
/* eslint-enable */
|
||||||
|
|
||||||
|
return <App {...props} />;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
20
resources/js/types/domain.ts
Normal file
20
resources/js/types/domain.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
5
resources/js/types/global.d.ts
vendored
Normal file
5
resources/js/types/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { route as routeFn } from 'ziggy-js';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
const route: typeof routeFn;
|
||||||
|
}
|
||||||
43
resources/js/types/index.d.ts
vendored
Normal file
43
resources/js/types/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
import type { Config } from 'ziggy-js';
|
||||||
|
|
||||||
|
export interface Auth {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavGroup {
|
||||||
|
title: string;
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
icon?: LucideIcon | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedData {
|
||||||
|
name: string;
|
||||||
|
quote: { message: string; author: string };
|
||||||
|
auth: Auth;
|
||||||
|
ziggy: Config & { location: string };
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
avatar?: string;
|
||||||
|
email_verified_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
[key: string]: unknown; // This allows for additional properties...
|
||||||
|
}
|
||||||
1
resources/js/types/vite-env.d.ts
vendored
Normal file
1
resources/js/types/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
53
resources/views/app.blade.php
Normal file
53
resources/views/app.blade.php
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const appearance = '{{ $appearance ?? "system" }}';
|
||||||
|
|
||||||
|
if (appearance === 'system') {
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
|
if (prefersDark) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{{-- Inline style to set the HTML background color based on our theme in app.css --}}
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
background-color: oklch(1 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
background-color: oklch(0.145 0 0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
|
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600|major-mono-display:400" rel="stylesheet" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/fonts/7segment.woff" as="font" type="font/woff" crossorigin>
|
||||||
|
|
||||||
|
@routes
|
||||||
|
@viteReactRefresh
|
||||||
|
@vite(['resources/js/app.tsx'])
|
||||||
|
@inertiaHead
|
||||||
|
</head>
|
||||||
|
<body class="font-sans antialiased">
|
||||||
|
@inertia
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
||||||
|
|
||||||
<title>{{ config('app.name', 'incr') }}</title>
|
|
||||||
|
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
||||||
|
|
||||||
<link rel="preload" href="/fonts/7segment.woff" as="font" type="font/woff" crossorigin>
|
|
||||||
|
|
||||||
@vite(['resources/css/app.css'])
|
|
||||||
@livewireStyles
|
|
||||||
</head>
|
|
||||||
<body class="bg-black antialiased">
|
|
||||||
{{ $slot }}
|
|
||||||
|
|
||||||
@livewireScripts
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
<div class="min-h-screen bg-black">
|
|
||||||
@if ($needsOnboarding)
|
|
||||||
<div class="min-h-screen flex items-center justify-center p-4">
|
|
||||||
<div class="w-full max-w-md">
|
|
||||||
<div class="border-2 border-red-500 bg-black shadow-[0_0_20px_rgba(239,68,68,0.3)] p-8">
|
|
||||||
<form wire:submit="initialise" class="space-y-4">
|
|
||||||
<label for="starting-value" class="text-red-400 font-mono text-xs uppercase tracking-wider">
|
|
||||||
> Starting Value
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="starting-value"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
autofocus
|
|
||||||
wire:model="value"
|
|
||||||
class="w-full 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 px-3 py-2"
|
|
||||||
>
|
|
||||||
@error('value')
|
|
||||||
<p class="text-red-400 font-mono text-xs">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="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 px-4 py-2"
|
|
||||||
>
|
|
||||||
[INITIALIZE]
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="w-full max-w-4xl mx-auto px-4">
|
|
||||||
<div class="pt-32">
|
|
||||||
<div
|
|
||||||
wire:click="increment"
|
|
||||||
class="w-full text-center select-none cursor-pointer bg-black text-red-500 px-8 py-12 transition-all duration-300"
|
|
||||||
>
|
|
||||||
<div class="relative w-full flex items-center justify-center">
|
|
||||||
<div
|
|
||||||
class="relative z-10 text-[8rem] md:text-[12rem] lg:text-[16rem] font-digital font-normal text-red-500 drop-shadow-[0_0_10px_rgba(239,68,68,0.8)] filter brightness-110 leading-none transition-all duration-300"
|
|
||||||
style="letter-spacing: 0.15em"
|
|
||||||
>{{ $count }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
wire:click="edit"
|
|
||||||
class="text-red-400/60 hover:text-red-400 font-mono text-xs uppercase tracking-widest transition-colors"
|
|
||||||
>
|
|
||||||
[SET VALUE]
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($editing)
|
|
||||||
<div class="bg-black p-8">
|
|
||||||
<div class="w-full border-4 border-red-500 p-6 bg-black glow-red">
|
|
||||||
<form wire:submit="save" class="space-y-4">
|
|
||||||
<label for="count" class="text-red-400 font-mono text-xs uppercase tracking-wider">
|
|
||||||
> Set Value
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="count"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
autofocus
|
|
||||||
wire:model="value"
|
|
||||||
class="w-full 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 px-3 py-2"
|
|
||||||
>
|
|
||||||
@error('value')
|
|
||||||
<p class="text-red-400 font-mono text-xs">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="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 px-4 py-2"
|
|
||||||
>
|
|
||||||
[EXECUTE]
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
wire:click="cancel"
|
|
||||||
class="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 px-4 py-2"
|
|
||||||
>
|
|
||||||
[ABORT]
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
8
routes/auth.php
Normal file
8
routes/auth.php
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Auth\RegisteredUserController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
// First-run setup only — gated by User::exists() in the controller
|
||||||
|
Route::get('register', [RegisteredUserController::class, 'create'])->name('register');
|
||||||
|
Route::post('register', [RegisteredUserController::class, 'store']);
|
||||||
|
|
@ -1,6 +1,54 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Livewire\Counter;
|
use App\Http\Controllers\AssetController;
|
||||||
|
use App\Http\Controllers\Milestones\MilestoneController;
|
||||||
|
use App\Http\Controllers\Pricing\PricingController;
|
||||||
|
use App\Http\Controllers\TrackerController;
|
||||||
|
use App\Http\Controllers\Transactions\EntryController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
Route::get('/', Counter::class)->name('home');
|
Route::get('/', function () {
|
||||||
|
return redirect('/dashboard');
|
||||||
|
})->name('home');
|
||||||
|
|
||||||
|
Route::get('dashboard', function () {
|
||||||
|
return Inertia::render('dashboard');
|
||||||
|
})->name('dashboard');
|
||||||
|
|
||||||
|
// Tracker routes
|
||||||
|
Route::get('/tracker', [TrackerController::class, 'show'])->name('tracker.show');
|
||||||
|
Route::post('/tracker', [TrackerController::class, 'store'])->name('tracker.store');
|
||||||
|
Route::patch('/tracker', [TrackerController::class, 'update'])->name('tracker.update');
|
||||||
|
|
||||||
|
// Asset routes
|
||||||
|
Route::prefix('assets')->name('assets.')->group(function () {
|
||||||
|
Route::get('/', [AssetController::class, 'index'])->name('index');
|
||||||
|
Route::post('/', [AssetController::class, 'store'])->name('store');
|
||||||
|
Route::get('/search', [AssetController::class, 'search'])->name('search');
|
||||||
|
Route::get('/{asset}', [AssetController::class, 'show'])->name('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Entry routes (replaces purchases)
|
||||||
|
Route::prefix('entries')->name('entries.')->group(function () {
|
||||||
|
Route::get('/', [EntryController::class, 'index'])->name('index');
|
||||||
|
Route::post('/', [EntryController::class, 'store'])->name('store');
|
||||||
|
Route::get('/summary', [EntryController::class, 'summary'])->name('summary');
|
||||||
|
Route::delete('/{entry}', [EntryController::class, 'destroy'])->name('destroy');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pricing routes
|
||||||
|
Route::prefix('pricing')->name('pricing.')->group(function () {
|
||||||
|
Route::get('/current', [PricingController::class, 'current'])->name('current');
|
||||||
|
Route::post('/update', [PricingController::class, 'update'])->name('update');
|
||||||
|
Route::get('/history', [PricingController::class, 'history'])->name('history');
|
||||||
|
Route::get('/date/{date}', [PricingController::class, 'forDate'])->name('for-date');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Milestone routes
|
||||||
|
Route::prefix('milestones')->name('milestones.')->group(function () {
|
||||||
|
Route::get('/', [MilestoneController::class, 'index'])->name('index');
|
||||||
|
Route::post('/', [MilestoneController::class, 'store'])->name('store');
|
||||||
|
});
|
||||||
|
|
||||||
|
require __DIR__.'/auth.php';
|
||||||
|
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class CountBackfillMigrationTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
private const MIGRATION = __DIR__.'/../../database/migrations/2026_08_15_000002_add_count_to_trackers_drop_entries.php';
|
|
||||||
|
|
||||||
// Each require returns a fresh anonymous-class instance; there is no name to collide.
|
|
||||||
private function migration(): Migration
|
|
||||||
{
|
|
||||||
return require self::MIGRATION;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuild the pre-migration shape: entries present, trackers without a count.
|
|
||||||
*/
|
|
||||||
private function revertToLedger(): void
|
|
||||||
{
|
|
||||||
$this->migration()->down();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeTracker(): int
|
|
||||||
{
|
|
||||||
return DB::table('trackers')->insertGetId([
|
|
||||||
'label' => 'Counter',
|
|
||||||
'unit' => 'units',
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function addEntry(int $trackerId, string $quantity): void
|
|
||||||
{
|
|
||||||
DB::table('entries')->insert([
|
|
||||||
'tracker_id' => $trackerId,
|
|
||||||
'date' => now()->toDateString(),
|
|
||||||
'quantity' => $quantity,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function countFor(int $trackerId): int
|
|
||||||
{
|
|
||||||
return (int) DB::table('trackers')->where('id', $trackerId)->value('count');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_backfill_sums_entries_into_count(): void
|
|
||||||
{
|
|
||||||
$this->revertToLedger();
|
|
||||||
|
|
||||||
$trackerId = $this->makeTracker();
|
|
||||||
$this->addEntry($trackerId, '10');
|
|
||||||
$this->addEntry($trackerId, '15');
|
|
||||||
|
|
||||||
$this->migration()->up();
|
|
||||||
|
|
||||||
$this->assertSame(25, $this->countFor($trackerId));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_backfill_rounds_fractional_totals(): void
|
|
||||||
{
|
|
||||||
$this->revertToLedger();
|
|
||||||
|
|
||||||
$trackerId = $this->makeTracker();
|
|
||||||
$this->addEntry($trackerId, '1.4');
|
|
||||||
$this->addEntry($trackerId, '1.2');
|
|
||||||
|
|
||||||
$this->migration()->up();
|
|
||||||
|
|
||||||
$this->assertSame(3, $this->countFor($trackerId));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_backfill_clamps_negative_totals_to_zero(): void
|
|
||||||
{
|
|
||||||
$this->revertToLedger();
|
|
||||||
|
|
||||||
$trackerId = $this->makeTracker();
|
|
||||||
$this->addEntry($trackerId, '5');
|
|
||||||
$this->addEntry($trackerId, '-9');
|
|
||||||
|
|
||||||
$this->migration()->up();
|
|
||||||
|
|
||||||
$this->assertSame(0, $this->countFor($trackerId));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_tracker_without_entries_defaults_to_zero(): void
|
|
||||||
{
|
|
||||||
$this->revertToLedger();
|
|
||||||
|
|
||||||
$trackerId = $this->makeTracker();
|
|
||||||
|
|
||||||
$this->migration()->up();
|
|
||||||
|
|
||||||
$this->assertSame(0, $this->countFor($trackerId));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_up_drops_the_entries_table(): void
|
|
||||||
{
|
|
||||||
$this->revertToLedger();
|
|
||||||
$this->assertTrue(Schema::hasTable('entries'));
|
|
||||||
|
|
||||||
$this->migration()->up();
|
|
||||||
|
|
||||||
$this->assertFalse(Schema::hasTable('entries'));
|
|
||||||
$this->assertTrue(Schema::hasColumn('trackers', 'count'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The two down() tests start from the migrated schema, so they do not call revertToLedger().
|
|
||||||
public function test_down_recreates_entries_from_count(): void
|
|
||||||
{
|
|
||||||
$trackerId = $this->makeTracker();
|
|
||||||
DB::table('trackers')->where('id', $trackerId)->update(['count' => 7]);
|
|
||||||
|
|
||||||
$this->migration()->down();
|
|
||||||
|
|
||||||
$this->assertTrue(Schema::hasTable('entries'));
|
|
||||||
$this->assertFalse(Schema::hasColumn('trackers', 'count'));
|
|
||||||
|
|
||||||
$entries = DB::table('entries')->where('tracker_id', $trackerId)->get();
|
|
||||||
|
|
||||||
$this->assertCount(1, $entries);
|
|
||||||
$this->assertSame(7.0, (float) $entries->first()->quantity);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_down_writes_no_entry_for_a_zero_count(): void
|
|
||||||
{
|
|
||||||
$trackerId = $this->makeTracker();
|
|
||||||
|
|
||||||
$this->migration()->down();
|
|
||||||
|
|
||||||
$this->assertSame(0, DB::table('entries')->where('tracker_id', $trackerId)->count());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
use App\Livewire\Counter;
|
|
||||||
use App\Models\Tracker;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class CounterTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
private function tracker(int $count = 0): Tracker
|
|
||||||
{
|
|
||||||
return Tracker::factory()->create(['count' => $count]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_the_page_renders_the_current_count(): void
|
|
||||||
{
|
|
||||||
$this->tracker(42);
|
|
||||||
|
|
||||||
$this->get('/')
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('42');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_increment_adds_exactly_one(): void
|
|
||||||
{
|
|
||||||
$tracker = $this->tracker(5);
|
|
||||||
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->call('increment')
|
|
||||||
->assertSet('count', 6);
|
|
||||||
|
|
||||||
$this->assertSame(6, $tracker->refresh()->count);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_increments_accumulate(): void
|
|
||||||
{
|
|
||||||
$tracker = $this->tracker();
|
|
||||||
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->call('increment')
|
|
||||||
->call('increment')
|
|
||||||
->call('increment')
|
|
||||||
->assertSet('count', 3);
|
|
||||||
|
|
||||||
$this->assertSame(3, $tracker->refresh()->count);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_count_can_be_set_to_an_absolute_value(): void
|
|
||||||
{
|
|
||||||
$tracker = $this->tracker(3);
|
|
||||||
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->call('edit')
|
|
||||||
->set('value', 250)
|
|
||||||
->call('save')
|
|
||||||
->assertSet('count', 250)
|
|
||||||
->assertSet('editing', false);
|
|
||||||
|
|
||||||
$this->assertSame(250, $tracker->refresh()->count);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_count_can_be_set_to_zero(): void
|
|
||||||
{
|
|
||||||
$tracker = $this->tracker(42);
|
|
||||||
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->call('edit')
|
|
||||||
->set('value', 0)
|
|
||||||
->call('save');
|
|
||||||
|
|
||||||
$this->assertSame(0, $tracker->refresh()->count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array{mixed}>
|
|
||||||
*/
|
|
||||||
public static function invalidCounts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'negative' => [-1],
|
|
||||||
'above unsigned int ceiling' => [4294967296],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
#[DataProvider('invalidCounts')]
|
|
||||||
public function test_save_rejects_invalid_values(mixed $value): void
|
|
||||||
{
|
|
||||||
$tracker = $this->tracker(7);
|
|
||||||
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->call('edit')
|
|
||||||
->set('value', $value)
|
|
||||||
->call('save')
|
|
||||||
->assertHasErrors('value');
|
|
||||||
|
|
||||||
$this->assertSame(7, $tracker->refresh()->count);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_cancel_discards_the_edit(): void
|
|
||||||
{
|
|
||||||
$tracker = $this->tracker(9);
|
|
||||||
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->call('edit')
|
|
||||||
->set('value', 500)
|
|
||||||
->call('cancel')
|
|
||||||
->assertSet('count', 9)
|
|
||||||
->assertSet('editing', false);
|
|
||||||
|
|
||||||
$this->assertSame(9, $tracker->refresh()->count);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_increment_stops_at_the_column_ceiling(): void
|
|
||||||
{
|
|
||||||
$tracker = $this->tracker(Counter::MAX_COUNT);
|
|
||||||
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->call('increment')
|
|
||||||
->assertSet('count', Counter::MAX_COUNT);
|
|
||||||
|
|
||||||
$this->assertSame(Counter::MAX_COUNT, $tracker->refresh()->count);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_count_is_cast_to_an_integer(): void
|
|
||||||
{
|
|
||||||
$this->tracker(12);
|
|
||||||
|
|
||||||
$this->assertSame(12, Tracker::first()->count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class DropAssetsMigrationTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
// Each require returns a fresh anonymous-class instance; there is no name to collide.
|
|
||||||
private function migration(): Migration
|
|
||||||
{
|
|
||||||
return require __DIR__.'/../../database/migrations/2026_08_15_000003_drop_assets_and_pricing.php';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_the_migrated_schema_has_no_asset_or_price_columns(): void
|
|
||||||
{
|
|
||||||
$this->assertFalse(Schema::hasTable('assets'));
|
|
||||||
$this->assertFalse(Schema::hasTable('asset_prices'));
|
|
||||||
$this->assertFalse(Schema::hasColumn('trackers', 'asset_id'));
|
|
||||||
$this->assertFalse(Schema::hasColumn('trackers', 'price_tracking_enabled'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_down_restores_the_tables_and_columns(): void
|
|
||||||
{
|
|
||||||
$this->migration()->down();
|
|
||||||
|
|
||||||
$this->assertTrue(Schema::hasTable('assets'));
|
|
||||||
$this->assertTrue(Schema::hasTable('asset_prices'));
|
|
||||||
$this->assertTrue(Schema::hasColumn('trackers', 'asset_id'));
|
|
||||||
$this->assertTrue(Schema::hasColumn('trackers', 'price_tracking_enabled'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_up_drops_them_again_respecting_foreign_keys(): void
|
|
||||||
{
|
|
||||||
$this->migration()->down();
|
|
||||||
$this->migration()->up();
|
|
||||||
|
|
||||||
$this->assertFalse(Schema::hasTable('assets'));
|
|
||||||
$this->assertFalse(Schema::hasTable('asset_prices'));
|
|
||||||
$this->assertFalse(Schema::hasColumn('trackers', 'asset_id'));
|
|
||||||
$this->assertFalse(Schema::hasColumn('trackers', 'price_tracking_enabled'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_dropping_assets_does_not_disturb_the_counter(): void
|
|
||||||
{
|
|
||||||
$this->migration()->down();
|
|
||||||
|
|
||||||
$trackerId = DB::table('trackers')->insertGetId([
|
|
||||||
'label' => 'Counter',
|
|
||||||
'unit' => 'units',
|
|
||||||
'count' => 31,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->migration()->up();
|
|
||||||
|
|
||||||
$this->assertSame(31, (int) DB::table('trackers')->where('id', $trackerId)->value('count'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class DropUsersMigrationTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
// Each require returns a fresh anonymous-class instance; there is no name to collide.
|
|
||||||
private function migration(): Migration
|
|
||||||
{
|
|
||||||
return require __DIR__.'/../../database/migrations/2026_08_15_000004_drop_users_and_sessions.php';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_the_migrated_schema_has_no_user_tables(): void
|
|
||||||
{
|
|
||||||
$this->assertFalse(Schema::hasTable('users'));
|
|
||||||
$this->assertFalse(Schema::hasTable('sessions'));
|
|
||||||
$this->assertFalse(Schema::hasTable('password_reset_tokens'));
|
|
||||||
$this->assertFalse(Schema::hasColumn('trackers', 'user_id'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_down_restores_the_tables_and_column(): void
|
|
||||||
{
|
|
||||||
$this->migration()->down();
|
|
||||||
|
|
||||||
$this->assertTrue(Schema::hasTable('users'));
|
|
||||||
$this->assertTrue(Schema::hasTable('sessions'));
|
|
||||||
$this->assertTrue(Schema::hasTable('password_reset_tokens'));
|
|
||||||
$this->assertTrue(Schema::hasColumn('trackers', 'user_id'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_restored_user_id_is_nullable_so_existing_trackers_survive(): void
|
|
||||||
{
|
|
||||||
DB::table('trackers')->insert([
|
|
||||||
'label' => 'Counter',
|
|
||||||
'unit' => 'units',
|
|
||||||
'count' => 3,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->migration()->down();
|
|
||||||
|
|
||||||
$nullable = DB::selectOne(
|
|
||||||
'select is_nullable from information_schema.columns
|
|
||||||
where table_schema = database() and table_name = ? and column_name = ?',
|
|
||||||
['trackers', 'user_id']
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertSame('YES', $nullable->is_nullable ?? $nullable->IS_NULLABLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_up_drops_them_again_respecting_foreign_keys(): void
|
|
||||||
{
|
|
||||||
$this->migration()->down();
|
|
||||||
$this->migration()->up();
|
|
||||||
|
|
||||||
$this->assertFalse(Schema::hasTable('users'));
|
|
||||||
$this->assertFalse(Schema::hasColumn('trackers', 'user_id'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_the_counter_survives_the_round_trip(): void
|
|
||||||
{
|
|
||||||
$trackerId = DB::table('trackers')->insertGetId([
|
|
||||||
'label' => 'Counter',
|
|
||||||
'unit' => 'units',
|
|
||||||
'count' => 77,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->migration()->down();
|
|
||||||
$this->migration()->up();
|
|
||||||
|
|
||||||
$this->assertSame(77, (int) DB::table('trackers')->where('id', $trackerId)->value('count'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
73
tests/Feature/MilestoneTest.php
Normal file
73
tests/Feature/MilestoneTest.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Milestone;
|
||||||
|
use App\Models\Tracker;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class MilestoneTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function tracker(): Tracker
|
||||||
|
{
|
||||||
|
return Tracker::create([
|
||||||
|
'user_id' => User::default()->id,
|
||||||
|
'label' => 'Test',
|
||||||
|
'unit' => 'units',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_create_milestone(): void
|
||||||
|
{
|
||||||
|
$tracker = $this->tracker();
|
||||||
|
|
||||||
|
$milestone = Milestone::create([
|
||||||
|
'tracker_id' => $tracker->id,
|
||||||
|
'target' => 1500,
|
||||||
|
'description' => 'First milestone',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('milestones', [
|
||||||
|
'target' => 1500,
|
||||||
|
'description' => 'First milestone',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(1500, $milestone->target);
|
||||||
|
$this->assertEquals('First milestone', $milestone->description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_fetch_milestones_via_api(): void
|
||||||
|
{
|
||||||
|
$tracker = $this->tracker();
|
||||||
|
Milestone::create(['tracker_id' => $tracker->id, 'target' => 1500, 'description' => 'First milestone']);
|
||||||
|
Milestone::create(['tracker_id' => $tracker->id, 'target' => 3000, 'description' => 'Second milestone']);
|
||||||
|
|
||||||
|
$response = $this->get('/milestones');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonCount(2);
|
||||||
|
$response->assertJson([
|
||||||
|
['target' => 1500, 'description' => 'First milestone'],
|
||||||
|
['target' => 3000, 'description' => 'Second milestone'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_milestones_ordered_by_target(): void
|
||||||
|
{
|
||||||
|
$tracker = $this->tracker();
|
||||||
|
Milestone::create(['tracker_id' => $tracker->id, 'target' => 3000, 'description' => 'Third']);
|
||||||
|
Milestone::create(['tracker_id' => $tracker->id, 'target' => 1000, 'description' => 'First']);
|
||||||
|
Milestone::create(['tracker_id' => $tracker->id, 'target' => 2000, 'description' => 'Second']);
|
||||||
|
|
||||||
|
$response = $this->get('/milestones');
|
||||||
|
|
||||||
|
$milestones = $response->json();
|
||||||
|
$this->assertEquals(1000, $milestones[0]['target']);
|
||||||
|
$this->assertEquals(2000, $milestones[1]['target']);
|
||||||
|
$this->assertEquals(3000, $milestones[2]['target']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
use App\Livewire\Counter;
|
|
||||||
use App\Models\Tracker;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class TrackerTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_a_fresh_install_asks_for_a_starting_value(): void
|
|
||||||
{
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->assertSet('needsOnboarding', true)
|
|
||||||
->assertSee('Starting Value');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_initialise_creates_the_counter(): void
|
|
||||||
{
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->set('value', 47)
|
|
||||||
->call('initialise')
|
|
||||||
->assertSet('needsOnboarding', false)
|
|
||||||
->assertSet('count', 47);
|
|
||||||
|
|
||||||
$tracker = Tracker::first();
|
|
||||||
|
|
||||||
$this->assertNotNull($tracker);
|
|
||||||
$this->assertSame(47, $tracker->count);
|
|
||||||
$this->assertSame('Counter', $tracker->label);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_a_counter_can_start_at_zero(): void
|
|
||||||
{
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->set('value', 0)
|
|
||||||
->call('initialise')
|
|
||||||
->assertSet('needsOnboarding', false)
|
|
||||||
->assertSet('count', 0);
|
|
||||||
|
|
||||||
$this->assertSame(0, Tracker::first()->count);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_a_zero_count_does_not_reopen_onboarding(): void
|
|
||||||
{
|
|
||||||
Tracker::factory()->create(['count' => 0]);
|
|
||||||
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->assertSet('needsOnboarding', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_initialise_rejects_a_negative_starting_value(): void
|
|
||||||
{
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->set('value', -5)
|
|
||||||
->call('initialise')
|
|
||||||
->assertHasErrors('value')
|
|
||||||
->assertSet('needsOnboarding', true);
|
|
||||||
|
|
||||||
$this->assertSame(0, Tracker::count());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_an_existing_counter_skips_onboarding(): void
|
|
||||||
{
|
|
||||||
Tracker::factory()->create(['count' => 9]);
|
|
||||||
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->assertSet('needsOnboarding', false)
|
|
||||||
->assertSet('count', 9);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_initialising_twice_does_not_create_a_second_counter(): void
|
|
||||||
{
|
|
||||||
$component = Livewire::test(Counter::class)->set('value', 5);
|
|
||||||
|
|
||||||
Tracker::factory()->create(['count' => 12]);
|
|
||||||
|
|
||||||
$component->call('initialise')->assertSet('count', 12);
|
|
||||||
|
|
||||||
$this->assertSame(1, Tracker::count());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
16
tests/Unit/ExampleTest.php
Normal file
16
tests/Unit/ExampleTest.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ExampleTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_that_true_is_true()
|
||||||
|
{
|
||||||
|
$this->assertTrue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
tsconfig.json
Normal file
122
tsconfig.json
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||||
|
|
||||||
|
/* Projects */
|
||||||
|
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||||
|
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||||
|
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||||
|
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||||
|
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||||
|
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||||
|
|
||||||
|
/* Language and Environment */
|
||||||
|
"target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||||
|
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||||
|
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||||
|
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||||
|
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||||
|
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||||
|
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||||
|
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||||
|
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||||
|
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||||
|
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||||
|
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||||
|
|
||||||
|
/* Modules */
|
||||||
|
"module": "ESNext" /* Specify what module code is generated. */,
|
||||||
|
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||||
|
"moduleResolution": "bundler" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
||||||
|
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||||
|
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||||
|
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||||
|
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||||
|
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||||
|
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||||
|
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||||
|
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||||
|
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
|
||||||
|
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||||
|
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||||
|
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||||
|
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
||||||
|
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||||
|
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||||
|
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||||
|
|
||||||
|
/* JavaScript Support */
|
||||||
|
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */,
|
||||||
|
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||||
|
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||||
|
|
||||||
|
/* Emit */
|
||||||
|
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||||
|
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||||
|
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||||
|
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||||
|
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||||
|
"noEmit": true /* Disable emitting files from a compilation. */,
|
||||||
|
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||||
|
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||||
|
// "removeComments": true, /* Disable emitting comments. */
|
||||||
|
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||||
|
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||||
|
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||||
|
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||||
|
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||||
|
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||||
|
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||||
|
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||||
|
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||||
|
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||||
|
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||||
|
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||||
|
|
||||||
|
/* Interop Constraints */
|
||||||
|
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
|
||||||
|
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||||
|
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
||||||
|
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||||
|
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
|
||||||
|
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||||
|
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||||
|
|
||||||
|
/* Type Checking */
|
||||||
|
"strict": true /* Enable all strict type-checking options. */,
|
||||||
|
"noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */,
|
||||||
|
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||||
|
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||||
|
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||||
|
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||||
|
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
||||||
|
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||||
|
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||||
|
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||||
|
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||||
|
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||||
|
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||||
|
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||||
|
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||||
|
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||||
|
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||||
|
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||||
|
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||||
|
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||||
|
|
||||||
|
/* Completeness */
|
||||||
|
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||||
|
"skipLibCheck": true /* Skip type checking all .d.ts files. */,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./resources/js/*"],
|
||||||
|
"ziggy-js": ["./vendor/tightenco/ziggy"]
|
||||||
|
},
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"resources/js/**/*.ts",
|
||||||
|
"resources/js/**/*.d.ts",
|
||||||
|
"resources/js/**/*.tsx",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,37 @@
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
import laravel from 'laravel-vite-plugin';
|
import laravel from 'laravel-vite-plugin';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
|
||||||
laravel({
|
|
||||||
input: ['resources/css/app.css'],
|
|
||||||
refresh: true,
|
|
||||||
}),
|
|
||||||
tailwindcss(),
|
|
||||||
],
|
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
|
||||||
cors: true,
|
|
||||||
hmr: {
|
hmr: {
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
|
clientPort: 5173,
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
usePolling: true,
|
||||||
|
ignored: ['**/storage/framework/views/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
laravel({
|
||||||
|
input: ['resources/css/app.css', 'resources/js/app.tsx'],
|
||||||
|
ssr: 'resources/js/ssr.tsx',
|
||||||
|
refresh: true,
|
||||||
|
}),
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
esbuild: {
|
||||||
|
jsx: 'automatic',
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'ziggy-js': resolve(__dirname, 'vendor/tightenco/ziggy'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue