From 0bec678b5a0122c936e5675fcfd2a4794d503283 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 21 Mar 2026 11:40:34 +0100 Subject: [PATCH] 12 - Add income distribution preview component --- .../components/IncomeDistributionPreview.tsx | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 resources/js/components/IncomeDistributionPreview.tsx diff --git a/resources/js/components/IncomeDistributionPreview.tsx b/resources/js/components/IncomeDistributionPreview.tsx new file mode 100644 index 0000000..fd42012 --- /dev/null +++ b/resources/js/components/IncomeDistributionPreview.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; + +interface Allocation { + bucket_id: string; + bucket_name: string; + bucket_type: 'need' | 'want' | 'overflow'; + allocated_amount: number; + remaining_capacity: number | null; +} + +interface PreviewResult { + allocations: Allocation[]; + total_allocated: number; + unallocated: number; +} + +interface IncomeDistributionPreviewProps { + scenarioId: string; +} + +const bucketTypeColor = { + need: 'bg-blue-100 text-blue-800', + want: 'bg-green-100 text-green-800', + overflow: 'bg-amber-100 text-amber-800', +} as const; + +const csrfToken = () => + document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; + +export default function IncomeDistributionPreview({ scenarioId }: IncomeDistributionPreviewProps) { + const [amount, setAmount] = useState(''); + const [preview, setPreview] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(null); + + const parsed = parseFloat(amount); + if (isNaN(parsed) || parsed <= 0) { + setError('Please enter a valid amount'); + setIsLoading(false); + return; + } + + try { + const response = await fetch(`/scenarios/${scenarioId}/projections/preview`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + body: JSON.stringify({ amount: parsed }), + }); + + if (!response.ok) { + let message = 'Failed to preview allocation'; + try { + const data = await response.json(); + message = data.message || message; + } catch { /* non-JSON error body */ } + setError(message); + setPreview(null); + return; + } + + const data: PreviewResult = await response.json(); + setPreview(data); + } catch { + setError('Failed to connect to server'); + setPreview(null); + } finally { + setIsLoading(false); + } + }; + + return ( + + + Income Distribution Preview + + +
+
+ + setAmount(e.target.value)} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-gray-900" + placeholder="e.g., 3000" + step="0.01" + min="0.01" + required + /> +
+ +
+ + {error && ( +
+ {error} +
+ )} + + {preview && ( +
+ {preview.allocations.length > 0 ? ( +
+ {preview.allocations.map((allocation) => ( +
+
+ + {allocation.bucket_type} + + {allocation.bucket_name} +
+
+ + ${allocation.allocated_amount.toFixed(2)} + + {allocation.remaining_capacity !== null && ( + + (${allocation.remaining_capacity.toFixed(2)} remaining) + + )} +
+
+ ))} +
+ ) : ( +

No buckets to allocate to.

+ )} + +
+ Total Allocated + ${preview.total_allocated.toFixed(2)} +
+ + {preview.unallocated > 0 && ( +
+ Unallocated + ${preview.unallocated.toFixed(2)} +
+ )} +
+ )} +
+
+ ); +}