Menu

Change language

GitHub repo

Personal Finance Budget

blockfinance

YNAB and Copilot-style zero-based monthly budget planner: envelope category budgeting, monthly pacing metrics, real-time spending progress by envelope, color-coded health indicators, and savings goal trackers.

Installation

$npx uipkge-ng@latest add personal-finance-budget
Manifest:https://uipkge.dev/r/angular/personal-finance-budget.jsonInstalls to:src/app/components/blocks/personal-finance-budget/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

EnvelopeCategory
interface EnvelopeCategory {
  id: string
  name: string
  icon: unknown
  budgeted: number
  spent: number
  note: string
  status: EnvelopeStatus
  statusLabel: string
  isSavings?: boolean
}
EnvelopeSection
interface EnvelopeSection {
  id: string
  title: string
  description: string
  icon: unknown
  totalBudgeted: number
  totalSpent: number
  categories: EnvelopeCategory[]
}

Files installed (2)

  • src/app/components/blocks/personal-finance-budget/personal-finance-budget.component.ts21.9 kB
    import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
    import {
      Calendar,
      CheckCircle2,
      Clock,
      CreditCard,
      Film,
      FolderPlus,
      HeartPulse,
      Home,
      Laptop,
      LucideAngularModule,
      PiggyBank,
      Plane,
      Plus,
      ShieldCheck,
      ShoppingCart,
      TrendingUp,
      UtensilsCrossed,
      Wallet,
      Zap,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiProgressComponent } from '@/ui/progress/progress.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    
    type EnvelopeStatus = 'healthy' | 'warning' | 'completed' | 'on-track'
    
    interface EnvelopeCategory {
      id: string
      name: string
      icon: unknown
      budgeted: number
      spent: number
      note: string
      status: EnvelopeStatus
      statusLabel: string
      isSavings?: boolean
    }
    
    interface EnvelopeSection {
      id: string
      title: string
      description: string
      icon: unknown
      totalBudgeted: number
      totalSpent: number
      categories: EnvelopeCategory[]
    }
    
    const sections: EnvelopeSection[] = [
      {
        id: 'immediate-obligations',
        title: 'Immediate Obligations',
        description: 'Non-negotiable fixed living expenses and essential monthly bills.',
        icon: Home,
        totalBudgeted: 3750,
        totalSpent: 3550,
        categories: [
          {
            id: 'cat-housing',
            name: 'Housing & Rent',
            icon: Home,
            budgeted: 2400,
            spent: 2400,
            note: 'Mortgage, HOA fee & insurance · Auto-paid Aug 1',
            status: 'completed',
            statusLabel: 'Paid in Full',
          },
          {
            id: 'cat-utilities',
            name: 'Utilities & Internet',
            icon: Zap,
            budgeted: 350,
            spent: 320,
            note: 'Electric ($185), Water ($75), Fiber ($60)',
            status: 'healthy',
            statusLabel: 'Healthy',
          },
          {
            id: 'cat-groceries',
            name: 'Groceries & Household',
            icon: ShoppingCart,
            budgeted: 800,
            spent: 650,
            note: 'Trader Joe’s & weekly produce · $15.00/day left',
            status: 'healthy',
            statusLabel: 'Healthy',
          },
          {
            id: 'cat-health',
            name: 'Health & Medical',
            icon: HeartPulse,
            budgeted: 200,
            spent: 180,
            note: 'Prescription copays & health membership',
            status: 'healthy',
            statusLabel: 'Healthy',
          },
        ],
      },
      {
        id: 'lifestyle-discretionary',
        title: 'Lifestyle & Discretionary',
        description: 'Variable lifestyle choices, dining, entertainment, and digital subscriptions.',
        icon: UtensilsCrossed,
        totalBudgeted: 700,
        totalSpent: 625,
        categories: [
          {
            id: 'cat-dining',
            name: 'Dining Out & Cafes',
            icon: UtensilsCrossed,
            budgeted: 400,
            spent: 380,
            note: 'Restaurants, coffee & takeout · $20.00 left for 10 days',
            status: 'warning',
            statusLabel: 'Near Limit',
          },
          {
            id: 'cat-entertainment',
            name: 'Entertainment & Leisure',
            icon: Film,
            budgeted: 200,
            spent: 150,
            note: 'Cinema tickets, gaming pass & weekend outings',
            status: 'healthy',
            statusLabel: 'Healthy',
          },
          {
            id: 'cat-tech',
            name: 'Tech & Subscriptions',
            icon: Laptop,
            budgeted: 100,
            spent: 95,
            note: 'iCloud, Spotify, GitHub Pro, ChatGPT Plus',
            status: 'warning',
            statusLabel: 'Near Limit',
          },
        ],
      },
      {
        id: 'savings-investments',
        title: 'Savings Goals & Investments',
        description: 'Dedicated wealth accumulation, emergency reserve, and planned sinking funds.',
        icon: PiggyBank,
        totalBudgeted: 1583,
        totalSpent: 1483,
        categories: [
          {
            id: 'cat-emergency',
            name: 'Emergency Fund',
            icon: ShieldCheck,
            budgeted: 500,
            spent: 500,
            note: 'High-yield cash reserve · Total balance: $18,500.00',
            status: 'completed',
            statusLabel: 'Goal Met',
            isSavings: true,
          },
          {
            id: 'cat-roth',
            name: 'Roth IRA Contribution',
            icon: TrendingUp,
            budgeted: 583,
            spent: 583,
            note: 'Broad-market index ETF · 2026 cap pacing ($7,000/yr)',
            status: 'completed',
            statusLabel: 'Maxed Monthly',
            isSavings: true,
          },
          {
            id: 'cat-vacation',
            name: 'Vacation & Travel Fund',
            icon: Plane,
            budgeted: 500,
            spent: 400,
            note: 'Tokyo autumn trip 2027 · $3,400.00 of $5,000.00 goal',
            status: 'on-track',
            statusLabel: '80% Funded',
            isSavings: true,
          },
        ],
      },
    ]
    
    function formatCurrency(amount: number): string {
      return `$${amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
    }
    
    /**
     * PersonalFinanceBudget -- the React block, 1:1: zero-based monthly budget header with
     * assignment pill, four overview cards (income, budgeted/spent, savings, month pace),
     * envelope category tables grouped by section with progress and health badges, and a
     * zero-based allocation philosophy footer.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-personal-finance-budget, [ui-personal-finance-budget]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiProgressComponent,
        UiTableComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <div data-slot="personal-finance-budget" [class]="rootClass">
          <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
            <div class="space-y-1.5">
              <div class="flex flex-wrap items-center gap-2.5">
                <h1 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">Monthly Zero-Based Budget</h1>
                <div
                  class="bg-muted text-foreground border-border flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-semibold"
                >
                  <lucide-icon [img]="Calendar" class="text-muted-foreground size-3.5" />
                  <span>August 2026</span>
                </div>
              </div>
              <div class="flex flex-wrap items-center gap-2 pt-0.5">
                <div
                  class="border-success/20 bg-success/10 text-success inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium"
                >
                  <lucide-icon [img]="CheckCircle2" class="size-3.5 shrink-0" />
                  <span
                    >To be Budgeted: <strong class="font-semibold tabular-nums">$0.00</strong> · Every dollar assigned!</span
                  >
                </div>
                <span class="text-muted-foreground hidden text-xs sm:inline">Zero-Based Envelope Method</span>
              </div>
            </div>
            <div class="flex items-center gap-2">
              <button ui-button variant="outline" size="sm" class="gap-1.5 shadow-xs">
                <lucide-icon [img]="FolderPlus" class="text-muted-foreground size-4" />
                <span>Add Category</span>
              </button>
              <button ui-button size="sm" class="gap-1.5 shadow-xs">
                <lucide-icon [img]="Plus" class="size-4" />
                <span>Add Expense</span>
              </button>
            </div>
          </div>
    
          <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            <div ui-card class="flex flex-col justify-between shadow-xs">
              <div ui-card-header class="pb-2">
                <div class="flex items-center justify-between gap-2">
                  <div class="flex items-center gap-2">
                    <div
                      aria-hidden="true"
                      class="border-success/20 bg-success/10 text-success flex size-8 shrink-0 items-center justify-center rounded-lg border shadow-xs"
                    >
                      <lucide-icon [img]="Wallet" class="size-4" />
                    </div>
                    <h3 ui-card-title class="text-muted-foreground text-xs font-medium">Total Monthly Income</h3>
                  </div>
                  <span ui-badge variant="success" class="text-xs">100% Assigned</span>
                </div>
              </div>
              <div ui-card-content class="space-y-2">
                <div>
                  <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums">$8,500.00</div>
                  <p class="text-muted-foreground mt-0.5 text-xs">2 Inflow streams (Salary & Freelance)</p>
                </div>
                <div class="border-border/60 text-muted-foreground flex items-center justify-between border-t pt-2 text-xs">
                  <span>Unassigned Pool</span>
                  <span class="text-success font-semibold tabular-nums">$0.00</span>
                </div>
              </div>
            </div>
    
            <div ui-card class="flex flex-col justify-between shadow-xs">
              <div ui-card-header class="pb-2">
                <div class="flex items-center justify-between gap-2">
                  <div class="flex items-center gap-2">
                    <div
                      aria-hidden="true"
                      class="bg-muted text-muted-foreground border-border flex size-8 shrink-0 items-center justify-center rounded-lg border shadow-xs"
                    >
                      <lucide-icon [img]="CreditCard" class="size-4" />
                    </div>
                    <h3 ui-card-title class="text-muted-foreground text-xs font-medium">Budgeted & Spent</h3>
                  </div>
                  <span ui-badge variant="warning" class="text-xs tabular-nums">80.5% Spent</span>
                </div>
              </div>
              <div ui-card-content class="space-y-2">
                <div>
                  <div class="flex items-baseline gap-1 text-2xl font-bold tracking-tight">
                    <span class="text-foreground tabular-nums">$6,840.00</span>
                    <span class="text-muted-foreground text-xs font-normal tabular-nums">/ $8,500.00</span>
                  </div>
                  <div class="mt-2 space-y-1">
                    <ui-progress [value]="80.5" class="[&_[data-slot=progress-indicator]]:bg-warning h-2" />
                  </div>
                </div>
                <div class="border-border/60 text-muted-foreground flex items-center justify-between border-t pt-2 text-xs">
                  <span>Remaining in Envelopes</span>
                  <span class="text-foreground font-medium tabular-nums">$1,660.00</span>
                </div>
              </div>
            </div>
    
            <div ui-card class="flex flex-col justify-between shadow-xs">
              <div ui-card-header class="pb-2">
                <div class="flex items-center justify-between gap-2">
                  <div class="flex items-center gap-2">
                    <div
                      aria-hidden="true"
                      class="border-success/20 bg-success/10 text-success flex size-8 shrink-0 items-center justify-center rounded-lg border shadow-xs"
                    >
                      <lucide-icon [img]="PiggyBank" class="size-4" />
                    </div>
                    <h3 ui-card-title class="text-muted-foreground text-xs font-medium">Savings Allocated</h3>
                  </div>
                  <span ui-badge variant="success" class="text-xs tabular-nums">19.5% Rate</span>
                </div>
              </div>
              <div ui-card-content class="space-y-2">
                <div>
                  <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums">$1,660.00</div>
                  <div class="mt-2 space-y-1">
                    <ui-progress [value]="89.3" class="[&_[data-slot=progress-indicator]]:bg-success h-2" />
                  </div>
                </div>
                <div class="border-border/60 text-muted-foreground flex items-center justify-between border-t pt-2 text-xs">
                  <span>Funded to Date</span>
                  <span class="text-success font-semibold tabular-nums">$1,483.00 (89.3%)</span>
                </div>
              </div>
            </div>
    
            <div ui-card class="flex flex-col justify-between shadow-xs">
              <div ui-card-header class="pb-2">
                <div class="flex items-center justify-between gap-2">
                  <div class="flex items-center gap-2">
                    <div
                      aria-hidden="true"
                      class="bg-muted text-muted-foreground border-border flex size-8 shrink-0 items-center justify-center rounded-lg border shadow-xs"
                    >
                      <lucide-icon [img]="Clock" class="size-4" />
                    </div>
                    <h3 ui-card-title class="text-muted-foreground text-xs font-medium">Month Pace</h3>
                  </div>
                  <span ui-badge variant="outline" class="text-xs font-normal tabular-nums">Day 21 of 31</span>
                </div>
              </div>
              <div ui-card-content class="space-y-2">
                <div>
                  <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums">10 Days left</div>
                  <div class="mt-2 space-y-1">
                    <ui-progress [value]="67.7" class="h-2" />
                  </div>
                </div>
                <div class="border-border/60 text-muted-foreground flex items-center justify-between border-t pt-2 text-xs">
                  <span>Cycle Elapsed</span>
                  <span class="text-foreground font-medium tabular-nums">67.7% of month</span>
                </div>
              </div>
            </div>
          </div>
    
          <div class="space-y-6">
            @for (section of sections; track section.id) {
              <div ui-card class="overflow-hidden shadow-xs">
                <div ui-card-header class="bg-muted/20 border-border border-b py-4">
                  <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                    <div class="space-y-0.5">
                      <div class="flex items-center gap-2">
                        <lucide-icon [img]="asIcon(section.icon)" class="text-muted-foreground size-4 shrink-0" />
                        <h3 ui-card-title class="text-base font-semibold">{{ section.title }}</h3>
                      </div>
                      <p ui-card-description class="text-xs">{{ section.description }}</p>
                    </div>
                    <div class="flex items-center gap-3 text-xs">
                      <div
                        class="bg-background border-border flex items-center gap-2 rounded-md border px-3 py-1.5 shadow-2xs"
                      >
                        <span class="text-muted-foreground">Subtotal:</span>
                        <span class="text-foreground font-semibold tabular-nums">{{ format(section.totalSpent) }}</span>
                        <span class="text-muted-foreground">/</span>
                        <span class="text-muted-foreground font-medium tabular-nums">{{
                          format(section.totalBudgeted)
                        }}</span>
                      </div>
                    </div>
                  </div>
                </div>
                <div ui-card-content class="p-0">
                  <div class="overflow-x-auto">
                    <table ui-table>
                      <thead ui-table-header>
                        <tr ui-table-row class="hover:bg-transparent">
                          <th ui-table-head class="min-w-[240px] text-xs font-semibold">Envelope Category</th>
                          <th ui-table-head class="text-right text-xs font-semibold">
                            {{ section.id === 'savings-investments' ? 'Monthly Target' : 'Budgeted' }}
                          </th>
                          <th ui-table-head class="text-right text-xs font-semibold">
                            {{ section.id === 'savings-investments' ? 'Funded to Date' : 'Spent to Date' }}
                          </th>
                          <th ui-table-head class="text-right text-xs font-semibold">
                            {{ section.id === 'savings-investments' ? 'Remaining to Goal' : 'Available Balance' }}
                          </th>
                          <th ui-table-head class="min-w-[180px] text-xs font-semibold">Progress & Pace</th>
                          <th ui-table-head class="w-[110px] text-right text-xs font-semibold">Status</th>
                        </tr>
                      </thead>
                      <tbody ui-table-body>
                        @for (cat of section.categories; track cat.id) {
                          <tr ui-table-row class="hover:bg-muted/40 transition-colors">
                            <td ui-table-cell class="py-3.5">
                              <div class="flex items-start gap-3">
                                <div
                                  aria-hidden="true"
                                  class="bg-muted text-muted-foreground border-border mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg border shadow-xs"
                                >
                                  <lucide-icon [img]="asIcon(cat.icon)" class="size-4" />
                                </div>
                                <div class="space-y-0.5">
                                  <p class="text-foreground text-xs font-semibold">{{ cat.name }}</p>
                                  <p class="text-muted-foreground text-xs">{{ cat.note }}</p>
                                </div>
                              </div>
                            </td>
                            <td ui-table-cell class="text-foreground py-3.5 text-right text-xs font-medium tabular-nums">
                              {{ format(cat.budgeted) }}
                            </td>
                            <td ui-table-cell class="text-foreground py-3.5 text-right text-xs font-medium tabular-nums">
                              {{ format(cat.spent) }}
                            </td>
                            <td ui-table-cell class="py-3.5 text-right text-xs">
                              <span [class]="remainingClass(cat)">{{ format(cat.budgeted - cat.spent) }}</span>
                            </td>
                            <td ui-table-cell class="py-3.5">
                              <div class="space-y-1.5">
                                <div class="flex items-center justify-between text-xs">
                                  <span class="text-muted-foreground tabular-nums">
                                    {{ percent(cat) }}% {{ cat.isSavings ? 'funded' : 'used' }}
                                  </span>
                                  <span class="text-muted-foreground text-xs tabular-nums">{{ format(cat.spent) }}</span>
                                </div>
                                <ui-progress [value]="percent(cat)" [class]="progressClass(cat)" />
                              </div>
                            </td>
                            <td ui-table-cell class="py-3.5 text-right">
                              <span ui-badge [variant]="badgeVariant(cat)" class="text-xs">{{ cat.statusLabel }}</span>
                            </td>
                          </tr>
                        }
                      </tbody>
                    </table>
                  </div>
                </div>
              </div>
            }
          </div>
    
          <div ui-card class="bg-muted/20 border-border p-4 shadow-xs">
            <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
              <div class="flex items-start gap-3">
                <div
                  aria-hidden="true"
                  class="border-success/20 bg-success/10 text-success mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg border shadow-xs"
                >
                  <lucide-icon [img]="CheckCircle2" class="size-4" />
                </div>
                <div class="space-y-0.5">
                  <h4 class="text-foreground text-xs font-semibold">Zero-Based Allocation Completed</h4>
                  <p class="text-muted-foreground text-xs">
                    Every dollar of your $8,500.00 income is accounted for: $4,450.00 living expenses + $1,583.00 savings
                    goals + $2,467.00 scheduled buffer = $0.00 unassigned.
                  </p>
                </div>
              </div>
              <div class="flex shrink-0 items-center gap-2">
                <span ui-badge variant="outline" class="text-xs font-normal">Next Payday: Sep 1, 2026</span>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiPersonalFinanceBudgetComponent {
      protected readonly Calendar = Calendar
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Clock = Clock
      protected readonly CreditCard = CreditCard
      protected readonly FolderPlus = FolderPlus
      protected readonly PiggyBank = PiggyBank
      protected readonly Plus = Plus
      protected readonly Wallet = Wallet
    
      @Input('class') className?: string
    
      protected readonly sections = sections
    
      get rootClass(): string {
        return cn('mx-auto w-full max-w-6xl space-y-6', this.className)
      }
    
      format(amount: number): string {
        return formatCurrency(amount)
      }
    
      percent(cat: EnvelopeCategory): number {
        return Math.min(100, Math.round((cat.spent / cat.budgeted) * 100))
      }
    
      asIcon(icon: unknown): never {
        return icon as never
      }
    
      remainingClass(cat: EnvelopeCategory): string {
        const remaining = cat.budgeted - cat.spent
        return cn(
          'font-bold tabular-nums',
          remaining === 0 ? 'text-muted-foreground' : cat.status === 'warning' ? 'text-warning' : 'text-success',
        )
      }
    
      progressClass(cat: EnvelopeCategory): string {
        return cn('h-1.5', this.progressColor(cat.status))
      }
    
      progressColor(status: EnvelopeStatus): string {
        if (status === 'warning') return '[&_[data-slot=progress-indicator]]:bg-warning'
        return '[&_[data-slot=progress-indicator]]:bg-success'
      }
    
      badgeVariant(cat: EnvelopeCategory): 'warning' | 'success' | 'outline' {
        if (cat.status === 'warning') return 'warning'
        if (cat.status === 'completed' || cat.status === 'healthy' || cat.status === 'on-track') return 'success'
        return 'outline'
      }
    }
    
  • src/app/components/blocks/personal-finance-budget/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/personal-finance-budget.json