50 - Collapse entry ledger to a scalar count column
This commit is contained in:
parent
2fe1f22282
commit
d482c6d3b7
13 changed files with 267 additions and 387 deletions
42
app/Http/Controllers/CounterController.php
Normal file
42
app/Http/Controllers/CounterController.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class CounterController extends Controller
|
||||||
|
{
|
||||||
|
public function increment(): JsonResponse
|
||||||
|
{
|
||||||
|
$tracker = User::default()->tracker;
|
||||||
|
|
||||||
|
if (! $tracker) {
|
||||||
|
return response()->json(['error' => 'No counter found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracker->increment('count');
|
||||||
|
|
||||||
|
return response()->json(['count' => $tracker->refresh()->count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'count' => 'required|integer|min:0|max:4294967295',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tracker = User::default()->tracker;
|
||||||
|
|
||||||
|
if (! $tracker) {
|
||||||
|
return response()->json(['error' => 'No counter found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracker->update(['count' => $validated['count']]);
|
||||||
|
|
||||||
|
return response()->json(['count' => $tracker->count]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Transactions;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Models\Transactions\Entry;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class EntryController extends Controller
|
|
||||||
{
|
|
||||||
public function index(): JsonResponse
|
|
||||||
{
|
|
||||||
$tracker = User::default()->tracker;
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return response()->json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json($tracker->entries()->orderBy('date', 'desc')->get());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'date' => 'required|date|before_or_equal:today',
|
|
||||||
'quantity' => 'required|numeric|min:0.000001',
|
|
||||||
'unit_price' => 'nullable|numeric|min:0.01',
|
|
||||||
'total_cost' => 'nullable|numeric|min:0.01',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tracker = User::default()->tracker;
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return back()->withErrors(['tracker' => 'No tracker found. Please complete onboarding first.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If unit_price and total_cost provided, verify the calculation
|
|
||||||
if (isset($validated['unit_price'], $validated['total_cost'])) {
|
|
||||||
$calculatedTotal = $validated['quantity'] * $validated['unit_price'];
|
|
||||||
if (abs($calculatedTotal - $validated['total_cost']) > 0.01) {
|
|
||||||
return back()->withErrors([
|
|
||||||
'total_cost' => 'Total cost does not match quantity × unit price.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$tracker->entries()->create($validated);
|
|
||||||
|
|
||||||
return back()->with('success', 'Entry added successfully!');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function summary(): JsonResponse
|
|
||||||
{
|
|
||||||
$tracker = User::default()->tracker;
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return response()->json([
|
|
||||||
'total_quantity' => 0,
|
|
||||||
'total_cost' => 0,
|
|
||||||
'average_cost_per_unit' => 0,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'total_quantity' => Entry::totalQuantity($tracker->id),
|
|
||||||
'total_cost' => Entry::totalCost($tracker->id),
|
|
||||||
'average_cost_per_unit' => Entry::averageCostPerUnit($tracker->id),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Entry $entry): JsonResponse
|
|
||||||
{
|
|
||||||
$tracker = User::default()->tracker;
|
|
||||||
|
|
||||||
if (! $tracker || $entry->tracker_id !== $tracker->id) {
|
|
||||||
return response()->json(['error' => 'Entry not found.'], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$entry->delete();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Entry deleted successfully!',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,10 +4,8 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\Transactions\Entry;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
class Tracker extends Model
|
class Tracker extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -16,12 +14,14 @@ class Tracker extends Model
|
||||||
'asset_id',
|
'asset_id',
|
||||||
'label',
|
'label',
|
||||||
'unit',
|
'unit',
|
||||||
|
'count',
|
||||||
'price_tracking_enabled',
|
'price_tracking_enabled',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'count' => 'integer',
|
||||||
'price_tracking_enabled' => 'boolean',
|
'price_tracking_enabled' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -35,9 +35,4 @@ public function asset(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Asset::class);
|
return $this->belongsTo(Asset::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function entries(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Entry::class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models\Transactions;
|
|
||||||
|
|
||||||
use App\Models\Tracker;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class Entry extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = [
|
|
||||||
'tracker_id',
|
|
||||||
'date',
|
|
||||||
'quantity',
|
|
||||||
'unit_price',
|
|
||||||
'total_cost',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'date' => 'date',
|
|
||||||
'quantity' => 'decimal:6',
|
|
||||||
'unit_price' => 'decimal:4',
|
|
||||||
'total_cost' => 'decimal:2',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tracker(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tracker::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function totalQuantity(int $trackerId): float
|
|
||||||
{
|
|
||||||
return (float) static::where('tracker_id', $trackerId)->sum('quantity');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function totalCost(int $trackerId): float
|
|
||||||
{
|
|
||||||
return (float) static::where('tracker_id', $trackerId)->sum('total_cost');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function averageCostPerUnit(int $trackerId): float
|
|
||||||
{
|
|
||||||
$totalQuantity = static::totalQuantity($trackerId);
|
|
||||||
$totalCost = static::totalCost($trackerId);
|
|
||||||
|
|
||||||
return $totalQuantity > 0 ? $totalCost / $totalQuantity : 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -46,9 +46,4 @@ public static function default(): self
|
||||||
'password' => bcrypt(Str::random(32)),
|
'password' => bcrypt(Str::random(32)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasEntries(): bool
|
|
||||||
{
|
|
||||||
return (bool) $this->tracker?->entries()->exists();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
95
resources/js/components/Counter/SetCountForm.tsx
Normal file
95
resources/js/components/Counter/SetCountForm.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import InputError from '@/components/InputError';
|
||||||
|
import { csrfToken } from '@/lib/utils';
|
||||||
|
import { LoaderCircle } from 'lucide-react';
|
||||||
|
import { FormEventHandler, useState } from 'react';
|
||||||
|
|
||||||
|
interface SetCountFormProps {
|
||||||
|
currentCount: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: (count: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SetCountForm({ currentCount, onClose, onSuccess }: SetCountFormProps) {
|
||||||
|
const [value, setValue] = useState(String(currentCount));
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const submit: FormEventHandler = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setProcessing(true);
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/count', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': csrfToken(),
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ count: Number(value) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError('Enter a whole number of zero or more.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count } = await response.json();
|
||||||
|
onSuccess(count);
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
setError('Something went wrong. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-black p-8">
|
||||||
|
<div className="w-full border-4 border-red-500 p-6 bg-black glow-red">
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<Label
|
||||||
|
htmlFor="count"
|
||||||
|
className="text-red-400 font-mono text-xs uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
> Set Value
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="count"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
autoFocus
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(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} />
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={processing || value === ''}
|
||||||
|
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>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
||||||
|
>
|
||||||
|
[ABORT]
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import AddEntryForm from '@/components/Transactions/AddEntryForm';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface InlineFormProps {
|
|
||||||
open: boolean;
|
|
||||||
unit?: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function InlineForm({
|
|
||||||
open,
|
|
||||||
unit = 'units',
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
className,
|
|
||||||
}: InlineFormProps) {
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const handleSuccess = () => {
|
|
||||||
onSuccess?.();
|
|
||||||
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">
|
|
||||||
<AddEntryForm
|
|
||||||
unit={unit}
|
|
||||||
onSuccess={handleSuccess}
|
|
||||||
onCancel={onClose}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import InputError from '@/components/InputError';
|
import InputError from '@/components/InputError';
|
||||||
import { todayISO } from '@/lib/utils';
|
import { csrfToken } from '@/lib/utils';
|
||||||
import { LoaderCircle } from 'lucide-react';
|
import { LoaderCircle } from 'lucide-react';
|
||||||
import { FormEventHandler, useState } from 'react';
|
import { FormEventHandler, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -10,10 +10,6 @@ interface OnboardingFlowProps {
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function csrfToken(): string {
|
|
||||||
return (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
||||||
const [startingValue, setStartingValue] = useState('0');
|
const [startingValue, setStartingValue] = useState('0');
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
|
@ -42,16 +38,16 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const quantity = Number(startingValue);
|
const count = Number(startingValue);
|
||||||
|
|
||||||
if (quantity > 0) {
|
if (count > 0) {
|
||||||
const entryResponse = await fetch(route('entries.store'), {
|
const countResponse = await fetch('/count', {
|
||||||
method: 'POST',
|
method: 'PATCH',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ date: todayISO(), quantity }),
|
body: JSON.stringify({ count }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!entryResponse.ok) {
|
if (!countResponse.ok) {
|
||||||
setError('Could not save the starting value. Please try again.');
|
setError('Could not save the starting value. Please try again.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import InputError from '@/components/InputError';
|
|
||||||
import { useForm } from '@inertiajs/react';
|
|
||||||
import { todayISO } from '@/lib/utils';
|
|
||||||
import { LoaderCircle } from 'lucide-react';
|
|
||||||
import { FormEventHandler, useEffect, useState } from 'react';
|
|
||||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
|
||||||
|
|
||||||
interface EntryFormData {
|
|
||||||
date: string;
|
|
||||||
quantity: string;
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AddEntryFormProps {
|
|
||||||
unit?: string;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EntrySummary {
|
|
||||||
total_quantity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddEntryForm({ unit = 'units', onSuccess, onCancel }: AddEntryFormProps) {
|
|
||||||
const { data, setData, post, processing, errors, reset } = useForm<EntryFormData>({
|
|
||||||
date: todayISO(),
|
|
||||||
quantity: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const [currentHoldings, setCurrentHoldings] = useState<EntrySummary | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchSummary = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/entries/summary');
|
|
||||||
if (response.ok) {
|
|
||||||
const summary = await response.json();
|
|
||||||
setCurrentHoldings(summary);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch entry summary:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchSummary();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const submit: FormEventHandler = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
post(route('entries.store'), {
|
|
||||||
onSuccess: () => {
|
|
||||||
reset();
|
|
||||||
setData('date', todayISO());
|
|
||||||
if (onSuccess) onSuccess();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<ComponentTitle>ADD ENTRY</ComponentTitle>
|
|
||||||
{currentHoldings && currentHoldings.total_quantity > 0 && (
|
|
||||||
<p className="text-sm text-red-400/60 font-mono">
|
|
||||||
[CURRENT] {currentHoldings.total_quantity.toFixed(6)} {unit}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<form onSubmit={submit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="date" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Date</Label>
|
|
||||||
<Input
|
|
||||||
id="date"
|
|
||||||
type="date"
|
|
||||||
value={data.date}
|
|
||||||
onChange={(e) => setData('date', e.target.value)}
|
|
||||||
max={todayISO()}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none transition-all glow-red"
|
|
||||||
/>
|
|
||||||
<InputError message={errors.date} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="quantity" className="text-red-400 font-mono text-xs uppercase tracking-wider">
|
|
||||||
> Quantity ({unit})
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="quantity"
|
|
||||||
type="number"
|
|
||||||
step="0.000001"
|
|
||||||
min="0"
|
|
||||||
placeholder="1.234567"
|
|
||||||
value={data.quantity}
|
|
||||||
onChange={(e) => setData('quantity', e.target.value)}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
|
|
||||||
/>
|
|
||||||
<InputError message={errors.quantity} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={processing}
|
|
||||||
className="flex-1 bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
[EXECUTE]
|
|
||||||
</Button>
|
|
||||||
{onCancel && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
[ABORT]
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -5,4 +5,5 @@ export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const todayISO = (): string => new Date().toISOString().split('T')[0];
|
export const csrfToken = (): string =>
|
||||||
|
(document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '';
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,30 @@
|
||||||
import LedDisplay from '@/components/Display/LedDisplay';
|
import LedDisplay from '@/components/Display/LedDisplay';
|
||||||
import InlineForm from '@/components/Display/InlineForm';
|
import SetCountForm from '@/components/Counter/SetCountForm';
|
||||||
import OnboardingFlow from '@/components/Onboarding/OnboardingFlow';
|
import OnboardingFlow from '@/components/Onboarding/OnboardingFlow';
|
||||||
import TerminalSpinner from '@/components/ui/TerminalSpinner';
|
import TerminalSpinner from '@/components/ui/TerminalSpinner';
|
||||||
|
import { csrfToken } from '@/lib/utils';
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import type { Tracker } from '@/types/domain';
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [totalShares, setTotalShares] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [incrementing, setIncrementing] = useState(false);
|
||||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||||
const [tracker, setTracker] = useState<Tracker | null>(null);
|
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
const [entriesResponse, trackerResponse] = await Promise.all([
|
const response = await fetch('/tracker');
|
||||||
fetch('/entries/summary'),
|
|
||||||
fetch('/tracker'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (entriesResponse.ok) {
|
if (!response.ok) {
|
||||||
const entries = await entriesResponse.json();
|
setNeedsOnboarding(true);
|
||||||
setTotalShares(entries.total_quantity);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let trackerExists = false;
|
const { exists, tracker } = await response.json();
|
||||||
|
|
||||||
if (trackerResponse.ok) {
|
setCount(tracker?.count ?? 0);
|
||||||
const { exists, tracker: trackerData } = await trackerResponse.json();
|
setNeedsOnboarding(!exists);
|
||||||
setTracker(trackerData ?? null);
|
|
||||||
trackerExists = Boolean(exists);
|
|
||||||
}
|
|
||||||
|
|
||||||
setNeedsOnboarding(!trackerExists);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -49,20 +41,36 @@ export default function Dashboard() {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
const handleEntrySuccess = async () => {
|
const increment = async () => {
|
||||||
|
if (incrementing) return;
|
||||||
|
|
||||||
|
const previous = count;
|
||||||
|
setIncrementing(true);
|
||||||
|
setCount(previous + 1);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entriesResponse = await fetch('/entries/summary');
|
const response = await fetch('/increment', {
|
||||||
if (entriesResponse.ok) {
|
method: 'POST',
|
||||||
const entries = await entriesResponse.json();
|
headers: {
|
||||||
setTotalShares(entries.total_quantity);
|
'X-CSRF-TOKEN': csrfToken(),
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setCount(previous);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to refresh entry data:', error);
|
const { count: updated } = await response.json();
|
||||||
|
setCount(updated);
|
||||||
|
} catch {
|
||||||
|
setCount(previous);
|
||||||
|
} finally {
|
||||||
|
setIncrementing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOnboardingComplete = loadData;
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -76,7 +84,7 @@ export default function Dashboard() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="incr - Setup" />
|
<Head title="incr - Setup" />
|
||||||
<OnboardingFlow onComplete={handleOnboardingComplete} />
|
<OnboardingFlow onComplete={loadData} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -88,18 +96,26 @@ export default function Dashboard() {
|
||||||
<div className="min-h-screen bg-black">
|
<div className="min-h-screen bg-black">
|
||||||
<div className="w-full max-w-4xl mx-auto px-4">
|
<div className="w-full max-w-4xl mx-auto px-4">
|
||||||
<div className="pt-32">
|
<div className="pt-32">
|
||||||
<LedDisplay
|
<LedDisplay value={count} onClick={increment} />
|
||||||
value={totalShares}
|
|
||||||
onClick={() => setFormOpen((open) => !open)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InlineForm
|
<div className="text-center">
|
||||||
open={formOpen}
|
<button
|
||||||
unit={tracker?.unit}
|
type="button"
|
||||||
|
onClick={() => setFormOpen(true)}
|
||||||
|
className="text-red-400/60 hover:text-red-400 font-mono text-xs uppercase tracking-widest transition-colors"
|
||||||
|
>
|
||||||
|
[SET VALUE]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formOpen && (
|
||||||
|
<SetCountForm
|
||||||
|
currentCount={count}
|
||||||
onClose={() => setFormOpen(false)}
|
onClose={() => setFormOpen(false)}
|
||||||
onSuccess={handleEntrySuccess}
|
onSuccess={setCount}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AssetController;
|
use App\Http\Controllers\AssetController;
|
||||||
|
use App\Http\Controllers\CounterController;
|
||||||
use App\Http\Controllers\Pricing\PricingController;
|
use App\Http\Controllers\Pricing\PricingController;
|
||||||
use App\Http\Controllers\TrackerController;
|
use App\Http\Controllers\TrackerController;
|
||||||
use App\Http\Controllers\Transactions\EntryController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
|
@ -28,13 +28,9 @@
|
||||||
Route::get('/{asset}', [AssetController::class, 'show'])->name('show');
|
Route::get('/{asset}', [AssetController::class, 'show'])->name('show');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Entry routes (replaces purchases)
|
// Counter routes
|
||||||
Route::prefix('entries')->name('entries.')->group(function () {
|
Route::post('/increment', [CounterController::class, 'increment'])->name('counter.increment');
|
||||||
Route::get('/', [EntryController::class, 'index'])->name('index');
|
Route::patch('/count', [CounterController::class, 'update'])->name('counter.update');
|
||||||
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
|
// Pricing routes
|
||||||
Route::prefix('pricing')->name('pricing.')->group(function () {
|
Route::prefix('pricing')->name('pricing.')->group(function () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue