diff --git a/public/fonts/7segment.woff b/public/fonts/7segment.woff new file mode 100644 index 0000000..09ab315 Binary files /dev/null and b/public/fonts/7segment.woff differ diff --git a/resources/css/app.css b/resources/css/app.css index 468baf5..5f6b7dc 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -5,6 +5,26 @@ @source '../views'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@font-face { + font-family: '7Segment'; + src: url('/fonts/7segment.woff') format('woff'); + font-weight: normal; + font-style: normal; +} + +.font-digital { + font-family: '7Segment', monospace; +} + +.glow-red { + box-shadow: 0 0 20px rgba(239, 68, 68, 0.4); + transition: box-shadow 300ms ease; +} + +.glow-red:hover { + box-shadow: 0 0 25px rgba(239, 68, 68, 0.6); +} + @custom-variant dark (&:is(.dark *)); @theme { @@ -61,80 +81,50 @@ @theme { --color-sidebar-ring: var(--sidebar-ring); } +/* 80s Digital Theme — dark only */ :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); + --background: oklch(0.1 0 0); + --foreground: oklch(0.637 0.237 25.331); + --card: oklch(0.12 0 0); + --card-foreground: oklch(0.637 0.237 25.331); + --popover: oklch(0.12 0 0); + --popover-foreground: oklch(0.637 0.237 25.331); + --primary: oklch(0.637 0.237 25.331); + --primary-foreground: oklch(0.1 0 0); + --secondary: oklch(0.2 0 0); + --secondary-foreground: oklch(0.637 0.237 25.331); + --muted: oklch(0.2 0 0); + --muted-foreground: oklch(0.5 0.1 25); + --accent: oklch(0.2 0 0); + --accent-foreground: oklch(0.637 0.237 25.331); --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); + --destructive-foreground: oklch(0.985 0 0); + --border: oklch(0.637 0.237 25.331); + --input: oklch(0.2 0 0); + --ring: oklch(0.637 0.237 25.331); + --chart-1: oklch(0.637 0.237 25.331); + --chart-2: oklch(0.75 0.18 70); + --chart-3: oklch(0.7 0.15 145); + --chart-4: oklch(0.75 0.15 55); + --chart-5: oklch(0.5 0.1 25); + --radius: 0; + --sidebar: oklch(0.1 0 0); + --sidebar-foreground: oklch(0.637 0.237 25.331); + --sidebar-primary: oklch(0.637 0.237 25.331); + --sidebar-primary-foreground: oklch(0.1 0 0); + --sidebar-accent: oklch(0.2 0 0); + --sidebar-accent-foreground: oklch(0.637 0.237 25.331); + --sidebar-border: oklch(0.637 0.237 25.331); + --sidebar-ring: oklch(0.637 0.237 25.331); } @layer base { - * { - @apply border-border; + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentColor); } body { diff --git a/resources/js/pages/Scenarios/Show.tsx b/resources/js/pages/Scenarios/Show.tsx index dfa1c57..5f9fc72 100644 --- a/resources/js/pages/Scenarios/Show.tsx +++ b/resources/js/pages/Scenarios/Show.tsx @@ -1,10 +1,9 @@ import { Head, router } from '@inertiajs/react'; +import { Settings } from 'lucide-react'; import { useState } from 'react'; -import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; import InlineEditInput from '@/components/InlineEditInput'; import InlineEditSelect from '@/components/InlineEditSelect'; -import IncomeDistributionPreview from '@/components/IncomeDistributionPreview'; +import SettingsPanel from '@/components/SettingsPanel'; import { csrfToken } from '@/lib/utils'; import { type Scenario } from '@/types'; @@ -26,46 +25,11 @@ interface Bucket { available_space: number | null; } -interface Stream { - id: string; - name: string; - type: 'income' | 'expense'; - type_label: string; - amount: number; - frequency: string; - frequency_label: string; - start_date: string; - end_date: string | null; - bucket_id: string | null; - bucket_name: string | null; - description: string | null; - is_active: boolean; - monthly_equivalent: number; -} - -interface StreamStats { - total_streams: number; - active_streams: number; - income_streams: number; - expense_streams: number; - monthly_income: number; - monthly_expenses: number; - monthly_net: number; -} - interface Props { scenario: Scenario; buckets: { data: Bucket[] }; - streams: { data: Stream[] }; - streamStats?: StreamStats; } -const bucketTypeBorderColor = { - need: 'border-blue-500', - want: 'border-green-500', - overflow: 'border-amber-500', -} as const; - const bucketTypeOptions = [ { value: 'need', label: 'Need' }, { value: 'want', label: 'Want' }, @@ -76,20 +40,13 @@ const allocationTypeOptions = [ { value: 'percentage', label: 'Percentage' }, ]; -/** Convert cents to dollars for display */ const centsToDollars = (cents: number): number => cents / 100; - -/** Convert dollars to cents for storage */ const dollarsToCents = (dollars: number): number => Math.round(dollars * 100); - -/** Convert basis points to percent for display */ const basisPointsToPercent = (bp: number): number => bp / 100; - -/** Convert percent to basis points for storage */ 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_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)}`; @@ -121,8 +78,9 @@ const defaultFormData = { buffer_mode: 'preset' as 'preset' | 'custom', }; -export default function Show({ scenario, buckets, streams = { data: [] }, streamStats }: Props) { +export default function Show({ scenario, buckets }: Props) { const [isModalOpen, setIsModalOpen] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [formData, setFormData] = useState({ ...defaultFormData }); @@ -132,9 +90,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream }; const handleDelete = async (bucket: Bucket) => { - if (!confirm(`Are you sure you want to delete "${bucket.name}"?`)) { - return; - } + if (!confirm(`Delete "${bucket.name}"?`)) return; try { const response = await fetch(`/buckets/${bucket.id}`, { @@ -144,8 +100,6 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream if (response.ok) { router.reload({ only: ['buckets'] }); - } else { - console.error('Failed to delete bucket'); } } catch (error) { console.error('Error deleting bucket:', error); @@ -209,79 +163,71 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream <> -
-
+
+
{/* Header */} -
-
-

{scenario.name}

-

- Water flows through the pipeline into prioritized buckets -

-
+
+

+ BUCKETS +

+
- {/* Bucket Dashboard */} + + + {/* Buckets */}
-

Buckets

+

+ BUCKETS +

{buckets.data.length === 0 ? ( - - - Set up your buckets - - Income flows through your pipeline into prioritized buckets. - Start by creating buckets for your spending categories. - - - -
-
-

Need

-

Essential expenses: rent, bills, groceries

-
-
-

Want

-

Discretionary spending: dining, hobbies, entertainment

-
-
-

Overflow

-

Catches all remaining funds — one per scenario

-
-
-
- - - -
+
+

+ NO BUCKETS CONFIGURED +

+

+ Income flows through your pipeline into prioritized buckets. +

+ +
) : (
{buckets.data.map((bucket) => (
-
+ {/* Header: name + priority */} +
-

+

patchBucket(bucket.id, { name: val })} />

-

- Priority {bucket.priority} •{' '} +

- {' '}•{' '} + {' | '} handlePriorityChange(bucket.id, 'up')} disabled={bucket.priority === 1} - className="p-1 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed" - title="Move up in priority" + className="p-1 text-red-500 hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed" + title="Move up" > - + + + #{bucket.priority} + - - #{bucket.priority} -

-
-
- Current Filling - patchBucket(bucket.id, { starting_amount: dollarsToCents(val) })} - formatDisplay={(v) => `$${v.toFixed(2)}`} - min={0} - step="0.01" - className="text-lg font-semibold text-gray-900" - /> -
+ {/* 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:{' '} - {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) - )} - - {bucket.allocation_type === 'fixed_limit' && ( - - ( - patchBucket(bucket.id, { buffer_multiplier: val })} - formatDisplay={(v) => v > 0 ? `${v}x buffer` : 'no buffer'} - min={0} - step="0.01" - /> - {bucket.buffer_multiplier > 0 && bucket.effective_capacity !== null && ( - <> = ${centsToDollars(bucket.effective_capacity).toFixed(2)} effective - )} - ) - + {/* 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) )} -
- - {bucket.allocation_type === 'fixed_limit' && ( -
-
- Progress - - ${centsToDollars(bucket.current_balance).toFixed(2)} / ${bucket.effective_capacity !== null ? centsToDollars(bucket.effective_capacity).toFixed(2) : '∞'} - -
-
-
-
-
- )} +
+ {/* 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' && ( -
+
)} @@ -414,297 +362,143 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream ))}
)} - - {/* Income Distribution Preview */} - {buckets.data.length > 0 && ( -
- -
- )} - - {/* Streams Section */} -
-
-

Income & Expense Streams

- -
- - {/* Stream Statistics */} - {streamStats && ( -
-
-
-
- Monthly Income -
-
- ${streamStats.monthly_income.toFixed(2)} -
-
-
-
-
-
- Monthly Expenses -
-
- ${streamStats.monthly_expenses.toFixed(2)} -
-
-
-
-
-
- Net Cash Flow -
-
= 0 ? 'text-green-600' : 'text-red-600'}`}> - ${streamStats.monthly_net.toFixed(2)} -
-
-
-
-
-
- Active Streams -
-
- {streamStats.active_streams} / {streamStats.total_streams} -
-
-
-
- )} - - {streams.data.length === 0 ? ( -
-

No streams yet. Add income or expense streams to start tracking cash flow.

-
- ) : ( -
- - - - - - - - - - - - - - - {streams.data.map((stream) => ( - - - - - - - - - - - ))} - -
- Name - - Type - - Amount - - Frequency - - Bucket - - Start Date - - Status - - Actions -
- {stream.name} - - - {stream.type_label} - - - ${stream.amount.toFixed(2)} - - {stream.frequency_label} - - {stream.bucket_name || '-'} - - {new Date(stream.start_date).toLocaleDateString()} - - - - - -
-
- )} -
-
{/* Create Bucket Modal */} {isModalOpen && ( -
-
-
-

Add New Bucket

-
+
+
+

+ ADD BUCKET +

+ +
+ + setFormData({ ...formData, name: e.target.value })} + className="w-full bg-black border-2 border-red-500/50 px-3 py-2 text-red-500 font-mono focus:border-red-500 focus:outline-none" + placeholder="e.g., Travel Fund" + required + /> +
+ +
+ + +
+ +
+ + +
+ + {formData.allocation_type !== 'unlimited' && (
-
+ )} + {formData.allocation_type === 'fixed_limit' && (
-
- -
- - -
- - {formData.allocation_type !== 'unlimited' && ( -
- + {formData.buffer_mode === 'custom' && ( setFormData({ ...formData, allocation_value: e.target.value })} - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-gray-900" - placeholder={formData.allocation_type === 'percentage' ? '25' : '1000'} - step={formData.allocation_type === 'percentage' ? '0.01' : '0.01'} - min={formData.allocation_type === 'percentage' ? '0.01' : '0'} - max={formData.allocation_type === 'percentage' ? '100' : undefined} - required + id="buffer_multiplier" + value={formData.buffer_multiplier} + onChange={(e) => setFormData({ ...formData, buffer_multiplier: e.target.value })} + className="mt-2 w-full bg-black border-2 border-red-500/50 px-3 py-2 text-red-500 font-mono font-digital focus:border-red-500 focus:outline-none" + placeholder="e.g., 0.75" + step="0.01" + min="0" /> -
- )} - - {formData.allocation_type === 'fixed_limit' && ( -
- - - {formData.buffer_mode === 'custom' && ( - setFormData({ ...formData, buffer_multiplier: e.target.value })} - className="mt-2 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-gray-900" - placeholder="e.g., 0.75" - step="0.01" - min="0" - /> - )} -
- )} - -
- - + )}
- -
+ )} + +
+ + +
+
)}