From 4006c7d2f313ae6aa1db11647d0dbdee826a35c9 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 22 Mar 2026 03:24:21 +0100 Subject: [PATCH] 2 - Add DigitalProgressBar and extract BucketCard component --- resources/js/components/BucketCard.tsx | 51 +++ .../js/components/DigitalProgressBar.tsx | 50 +++ resources/js/pages/Scenarios/Show.tsx | 415 +++++++++--------- resources/js/types/index.d.ts | 18 + 4 files changed, 333 insertions(+), 201 deletions(-) create mode 100644 resources/js/components/BucketCard.tsx create mode 100644 resources/js/components/DigitalProgressBar.tsx diff --git a/resources/js/components/BucketCard.tsx b/resources/js/components/BucketCard.tsx new file mode 100644 index 0000000..ef20e15 --- /dev/null +++ b/resources/js/components/BucketCard.tsx @@ -0,0 +1,51 @@ +import DigitalProgressBar from '@/components/DigitalProgressBar'; +import { type Bucket } from '@/types'; + +const centsToDollars = (cents: number): number => cents / 100; +const basisPointsToPercent = (bp: number): number => bp / 100; +const formatDollars = (cents: number): string => `$${centsToDollars(cents).toFixed(0)}`; + +export default function BucketCard({ bucket }: { bucket: Bucket }) { + const hasFiniteCapacity = bucket.allocation_type === 'fixed_limit' && bucket.effective_capacity !== null; + + return ( +
+ {/* Name */} +
+ + {bucket.name} + +
+ + {/* Progress bars or text — fixed height */} +
+ {hasFiniteCapacity ? ( +
+ +
+ ) : ( +
+ + {bucket.allocation_type === 'unlimited' ? '~ ALL REMAINING ~' : `~ ${basisPointsToPercent(bucket.allocation_value ?? 0).toFixed(2)}% ~`} + +
+ )} +
+ + {/* Amount */} +
+ + {formatDollars(bucket.current_balance)} + + {hasFiniteCapacity && ( + + {' '}/ {formatDollars(bucket.effective_capacity!)} + + )} +
+
+ ); +} diff --git a/resources/js/components/DigitalProgressBar.tsx b/resources/js/components/DigitalProgressBar.tsx new file mode 100644 index 0000000..0751bcf --- /dev/null +++ b/resources/js/components/DigitalProgressBar.tsx @@ -0,0 +1,50 @@ +interface DigitalProgressBarProps { + current: number; + capacity: number; +} + +const BAR_COUNT = 20; + +function getGlow(index: number): string { + if (index < 10) return '0 0 8px rgba(239, 68, 68, 0.6)'; + if (index < 18) return '0 0 8px rgba(249, 115, 22, 0.6)'; + return '0 0 8px rgba(34, 197, 94, 0.6)'; +} + +function getBarColor(index: number): string { + if (index < 10) return 'bg-red-500'; + if (index < 18) return 'bg-orange-500'; + return 'bg-green-500'; +} + +export default function DigitalProgressBar({ current, capacity }: DigitalProgressBarProps) { + const filledBars = capacity > 0 + ? Math.min(Math.round((current / capacity) * BAR_COUNT), BAR_COUNT) + : 0; + + return ( +
+ {Array.from({ length: BAR_COUNT }, (_, i) => { + const filled = i < filledBars; + return ( +
+ ); + })} +
+ ); +} diff --git a/resources/js/pages/Scenarios/Show.tsx b/resources/js/pages/Scenarios/Show.tsx index 30c7ac7..5fc2eb8 100644 --- a/resources/js/pages/Scenarios/Show.tsx +++ b/resources/js/pages/Scenarios/Show.tsx @@ -1,29 +1,12 @@ import { Head, router } from '@inertiajs/react'; import { Settings } from 'lucide-react'; import { useState } from 'react'; +import BucketCard from '@/components/BucketCard'; import InlineEditInput from '@/components/InlineEditInput'; import InlineEditSelect from '@/components/InlineEditSelect'; import SettingsPanel from '@/components/SettingsPanel'; import { csrfToken } from '@/lib/utils'; -import { type Scenario } from '@/types'; - -interface Bucket { - id: string; - name: string; - type: 'need' | 'want' | 'overflow'; - type_label: string; - priority: number; - sort_order: number; - allocation_type: string; - allocation_value: number | null; - allocation_type_label: string; - buffer_multiplier: number; - effective_capacity: number | null; - starting_amount: number; - current_balance: number; - has_available_space: boolean; - available_space: number | null; -} +import { type Bucket, type Scenario } from '@/types'; interface Props { scenario: Scenario; @@ -45,13 +28,6 @@ const dollarsToCents = (dollars: number): number => Math.round(dollars * 100); const basisPointsToPercent = (bp: number): number => bp / 100; const percentToBasisPoints = (pct: number): number => Math.round(pct * 100); -const formatAllocationValue = (bucket: Bucket): string => { - if (bucket.allocation_type === 'unlimited') return 'ALL REMAINING'; - if (bucket.allocation_value === null) return '--'; - if (bucket.allocation_type === 'percentage') return `${basisPointsToPercent(bucket.allocation_value).toFixed(2)}%`; - return `$${centsToDollars(bucket.allocation_value).toFixed(2)}`; -}; - const patchBucket = async (bucketId: string, data: Record): Promise => { const response = await fetch(`/buckets/${bucketId}`, { method: 'PATCH', @@ -79,14 +55,15 @@ const defaultFormData = { }; export default function Show({ scenario, buckets }: Props) { - const [isModalOpen, setIsModalOpen] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [editingBucket, setEditingBucket] = useState(null); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [formData, setFormData] = useState({ ...defaultFormData }); const openCreateModal = () => { setFormData({ ...defaultFormData }); - setIsModalOpen(true); + setIsCreateModalOpen(true); }; const handleDelete = async (bucket: Bucket) => { @@ -99,6 +76,7 @@ export default function Show({ scenario, buckets }: Props) { }); if (response.ok) { + setEditingBucket(null); router.reload({ only: ['buckets'] }); } } catch (error) { @@ -131,7 +109,7 @@ export default function Show({ scenario, buckets }: Props) { }); if (response.ok) { - setIsModalOpen(false); + setIsCreateModalOpen(false); setFormData({ ...defaultFormData }); router.reload({ only: ['buckets'] }); } else { @@ -186,190 +164,76 @@ export default function Show({ scenario, buckets }: Props) {
- {/* Buckets */} -
-
- -
- - {buckets.data.length === 0 ? ( -
-

- NO BUCKETS CONFIGURED -

-

- Income flows through your pipeline into prioritized buckets. -

+ {/* Two-column layout */} +
+ {/* Left: Buckets */} +
+
+

+ BUCKETS +

- ) : ( -
- {buckets.data.map((bucket) => ( -
+

+ NO BUCKETS CONFIGURED +

+

+ Income flows through your pipeline into prioritized buckets. +

+ - - #{bucket.priority} - - -
-
+ CREATE FIRST BUCKET + +
+ ) : ( +
+ {buckets.data.map((bucket) => ( + + ))} +
+ )} +
- {/* Filling */} -
- Current - patchBucket(bucket.id, { starting_amount: dollarsToCents(val) })} - formatDisplay={(v) => `$${v.toFixed(2)}`} - min={0} - step="0.01" - className="font-digital text-red-500" - /> -
- - {/* Allocation */} -
- Allocation - - {bucket.allocation_type === 'fixed_limit' ? ( - patchBucket(bucket.id, { allocation_value: dollarsToCents(val) })} - formatDisplay={(v) => `$${v.toFixed(2)}`} - min={0} - step="0.01" - /> - ) : bucket.allocation_type === 'percentage' ? ( - patchBucket(bucket.id, { allocation_value: percentToBasisPoints(val) })} - formatDisplay={(v) => `${v.toFixed(2)}%`} - min={0} - step="0.01" - /> - ) : ( - formatAllocationValue(bucket) - )} - -
- - {/* Buffer */} - {bucket.allocation_type === 'fixed_limit' && ( -
- Buffer - - patchBucket(bucket.id, { buffer_multiplier: val })} - formatDisplay={(v) => v > 0 ? `${v}x` : 'NONE'} - min={0} - step="0.01" - /> - -
- )} - - {/* Progress bar placeholder — will be replaced with DigitalProgressBar */} - {bucket.allocation_type === 'fixed_limit' && bucket.effective_capacity !== null && ( -
-
- PROGRESS - - ${centsToDollars(bucket.current_balance).toFixed(2)} / ${centsToDollars(bucket.effective_capacity).toFixed(2)} - -
-
-
-
-
- )} - - {/* Delete */} - {bucket.type !== 'overflow' && ( -
- -
- )} -
- ))} + {/* Right: Income */} +
+
+

+ INCOME +

- )} +
+ +
+
{/* Create Bucket Modal */} - {isModalOpen && ( + {isCreateModalOpen && (

@@ -484,7 +348,7 @@ export default function Show({ scenario, buckets }: Props) {

)} + + {/* Edit Bucket Modal */} + {editingBucket && ( +
{ if (e.target === e.currentTarget) setEditingBucket(null); }} + > +
+
+

+ EDIT BUCKET +

+ +
+ +
+ {/* Name */} +
+ NAME + patchBucket(editingBucket.id, { name: val })} + className="text-red-500 font-mono font-bold" + /> +
+ + {/* Type */} +
+ TYPE + patchBucket(editingBucket.id, { type: val })} + displayLabel={editingBucket.type_label} + disabled={editingBucket.type === 'overflow'} + className="text-red-500 font-mono" + /> +
+ + {/* Allocation Type */} +
+ ALLOCATION + patchBucket(editingBucket.id, { allocation_type: val, allocation_value: null })} + displayLabel={editingBucket.allocation_type_label} + disabled={editingBucket.type === 'overflow'} + className="text-red-500 font-mono" + /> +
+ + {/* Allocation Value */} + {editingBucket.allocation_type !== 'unlimited' && ( +
+ VALUE + + {editingBucket.allocation_type === 'fixed_limit' ? ( + patchBucket(editingBucket.id, { allocation_value: dollarsToCents(val) })} + formatDisplay={(v) => `$${v.toFixed(2)}`} + min={0} + step="0.01" + /> + ) : ( + patchBucket(editingBucket.id, { allocation_value: percentToBasisPoints(val) })} + formatDisplay={(v) => `${v.toFixed(2)}%`} + min={0} + step="0.01" + /> + )} + +
+ )} + + {/* Current Balance */} +
+ CURRENT + patchBucket(editingBucket.id, { starting_amount: dollarsToCents(val) })} + formatDisplay={(v) => `$${v.toFixed(2)}`} + min={0} + step="0.01" + className="font-digital text-red-500" + /> +
+ + {/* Buffer */} + {editingBucket.allocation_type === 'fixed_limit' && ( +
+ BUFFER + patchBucket(editingBucket.id, { buffer_multiplier: val })} + formatDisplay={(v) => v > 0 ? `${v}x` : 'NONE'} + min={0} + step="0.01" + className="font-digital text-red-500" + /> +
+ )} + + {/* Priority */} +
+ PRIORITY +
+ + #{editingBucket.priority} + +
+
+ + {/* Delete */} + {editingBucket.type !== 'overflow' && ( +
+ +
+ )} +
+
+
+ )} ); } diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 92e7aed..0f409b4 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -22,6 +22,24 @@ export interface NavItem { isActive?: boolean; } +export interface Bucket { + id: string; + name: string; + type: 'need' | 'want' | 'overflow'; + type_label: string; + priority: number; + sort_order: number; + allocation_type: string; + allocation_value: number | null; + allocation_type_label: string; + buffer_multiplier: number; + effective_capacity: number | null; + starting_amount: number; + current_balance: number; + has_available_space: boolean; + available_space: number | null; +} + export interface Scenario { id: string; name: string;