Menu

Change language

GitHub repo

Loan Calculator

blockfinance

Interactive mortgage and commercial loan amortization schedule calculator with dynamic sliders, loan type presets, extra payment impact simulation, principal vs interest visual ratio bar, and year-by-year amortization breakdown table.

Installation

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

Variants

Loading interactive previews…

Schema

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

LoanPreset
interface LoanPreset {
  id: LoanType
  name: string
  amount: number
  rate: number
  termYears: number
  extraPayment: number
  description: string
}
MonthRecord
interface MonthRecord {
  month: number
  principalPaid: number
  interestPaid: number
  extraPaid: number
  totalPaid: number
  remainingBalance: number
}
YearRecord
interface YearRecord {
  year: number
  principalPaid: number
  interestPaid: number
  extraPaid: number
  totalPaid: number
  remainingBalance: number
}
AmortizationResult
interface AmortizationResult {
  monthlySchedule: MonthRecord[]
  yearlySchedule: YearRecord[]
  actualMonths: number
  actualYears: string
  monthsSaved: number
  yearsSaved: string
  totalInterestPaid: number
  totalLoanCost: number
  totalExtraPaid: number
  interestSavings: number
}

Files installed (2)

  • src/app/components/blocks/loan-calculator/loan-calculator.component.ts32.2 kB
    // Interactive mortgage and commercial loan amortization schedule calculator with dynamic
    // sliders, loan type presets, extra payment impact simulation, principal vs interest visual
    // ratio bar, and year-by-year amortization breakdown table. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      ArrowRight,
      Building2,
      Calculator,
      Calendar,
      Car,
      CheckCircle2,
      DollarSign,
      FileSpreadsheet,
      Home,
      LucideAngularModule,
      PiggyBank,
      Wallet,
    } 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,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiSliderComponent } from '@/ui/slider/slider.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableFooterComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    import { UiTabsComponent, UiTabsListComponent, UiTabsTriggerComponent } from '@/ui/tabs/tabs.component'
    
    export type LoanType = 'mortgage-30' | 'commercial-10' | 'auto-5' | 'personal-3'
    
    export interface LoanPreset {
      id: LoanType
      name: string
      amount: number
      rate: number
      termYears: number
      extraPayment: number
      description: string
    }
    
    export const LOAN_PRESETS: Record<LoanType, LoanPreset> = {
      'mortgage-30': {
        id: 'mortgage-30',
        name: 'Mortgage 30Y',
        amount: 450_000,
        rate: 6.5,
        termYears: 30,
        extraPayment: 200,
        description: '30-year fixed home mortgage',
      },
      'commercial-10': {
        id: 'commercial-10',
        name: 'Commercial 10Y',
        amount: 1_200_000,
        rate: 7.25,
        termYears: 10,
        extraPayment: 500,
        description: '10-year commercial real estate loan',
      },
      'auto-5': {
        id: 'auto-5',
        name: 'Auto Loan 5Y',
        amount: 45_000,
        rate: 5.9,
        termYears: 5,
        extraPayment: 50,
        description: '60-month standard vehicle loan',
      },
      'personal-3': {
        id: 'personal-3',
        name: 'Personal Loan',
        amount: 25_000,
        rate: 9.5,
        termYears: 3,
        extraPayment: 25,
        description: '36-month fixed unsecured personal loan',
      },
    }
    
    export const LOAN_AMOUNT_MARKS: Record<number, string> = {
      100000: '$100K',
      500000: '$500K',
      1000000: '$1M',
      2000000: '$2M',
    }
    
    export const INTEREST_RATE_MARKS: Record<number, string> = {
      1: '1%',
      5: '5%',
      10: '10%',
      15: '15%',
    }
    
    export const LOAN_TERM_MARKS: Record<number, string> = {
      5: '5Y',
      10: '10Y',
      15: '15Y',
      20: '20Y',
      30: '30Y',
    }
    
    export const EXTRA_PAYMENT_MARKS: Record<number, string> = {
      0: '$0',
      500: '$500',
      1000: '$1K',
      2000: '$2K',
    }
    
    export const TERM_PRESETS = [5, 10, 15, 20, 30]
    
    export interface MonthRecord {
      month: number
      principalPaid: number
      interestPaid: number
      extraPaid: number
      totalPaid: number
      remainingBalance: number
    }
    
    export interface YearRecord {
      year: number
      principalPaid: number
      interestPaid: number
      extraPaid: number
      totalPaid: number
      remainingBalance: number
    }
    
    export function fmtCurrency(val: number): string {
      return new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: 'USD',
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
      }).format(val || 0)
    }
    
    export function fmtCurrencyWhole(val: number): string {
      return new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: 'USD',
        minimumFractionDigits: 0,
        maximumFractionDigits: 0,
      }).format(val || 0)
    }
    
    export function baseMonthlyPaymentFor(principal: number, annualRate: number, termYears: number): number {
      const totalMonths = termYears * 12
      if (principal <= 0 || totalMonths <= 0) return 0
      if (annualRate <= 0) return principal / totalMonths
      const monthlyRate = annualRate / 100 / 12
      const factor = Math.pow(1 + monthlyRate, totalMonths)
      if (!isFinite(factor) || factor <= 1) return principal / totalMonths
      return (principal * (monthlyRate * factor)) / (factor - 1)
    }
    
    export interface AmortizationResult {
      monthlySchedule: MonthRecord[]
      yearlySchedule: YearRecord[]
      actualMonths: number
      actualYears: string
      monthsSaved: number
      yearsSaved: string
      totalInterestPaid: number
      totalLoanCost: number
      totalExtraPaid: number
      interestSavings: number
    }
    
    export function amortize(
      loanAmount: number,
      interestRate: number,
      loanTermYears: number,
      extraMonthlyPayment: number,
      basePayment: number,
      totalInterestBase: number,
    ): AmortizationResult {
      const principal = Math.max(0, loanAmount)
      const annualRate = Math.max(0, interestRate)
      const termYears = Math.max(1, loanTermYears)
      const extra = Math.max(0, extraMonthlyPayment)
      const monthlyRate = annualRate / 100 / 12
      const standardMonths = termYears * 12
    
      let balance = principal
      const monthlySchedule: MonthRecord[] = []
      let totalInterest = 0
      let totalPaid = 0
      let totalExtra = 0
      let month = 0
      const maxMonths = 1200
    
      while (balance > 0.005 && month < maxMonths) {
        month++
        const interestForMonth = annualRate > 0 ? balance * monthlyRate : 0
        let scheduledTotal = basePayment + extra
        if (balance + interestForMonth < scheduledTotal) scheduledTotal = balance + interestForMonth
        const principalForMonth = Math.min(balance, Math.max(0, scheduledTotal - interestForMonth))
        const extraForMonth = Math.max(0, scheduledTotal - basePayment)
        balance = Math.max(0, balance - principalForMonth)
        totalInterest += interestForMonth
        totalPaid += scheduledTotal
        totalExtra += extraForMonth
        monthlySchedule.push({
          month,
          principalPaid: principalForMonth,
          interestPaid: interestForMonth,
          extraPaid: extraForMonth,
          totalPaid: scheduledTotal,
          remainingBalance: balance,
        })
      }
    
      const yearlySchedule: YearRecord[] = []
      const totalYearsActual = Math.ceil(monthlySchedule.length / 12)
      for (let y = 1; y <= totalYearsActual; y++) {
        const yearMonths = monthlySchedule.slice((y - 1) * 12, Math.min(y * 12, monthlySchedule.length))
        if (yearMonths.length === 0) continue
        yearlySchedule.push({
          year: y,
          principalPaid: yearMonths.reduce((s, m) => s + m.principalPaid, 0),
          interestPaid: yearMonths.reduce((s, m) => s + m.interestPaid, 0),
          extraPaid: yearMonths.reduce((s, m) => s + m.extraPaid, 0),
          totalPaid: yearMonths.reduce((s, m) => s + m.totalPaid, 0),
          remainingBalance: yearMonths[yearMonths.length - 1]?.remainingBalance ?? 0,
        })
      }
    
      const monthsSaved = Math.max(0, standardMonths - monthlySchedule.length)
      return {
        monthlySchedule,
        yearlySchedule,
        actualMonths: monthlySchedule.length,
        actualYears: (monthlySchedule.length / 12).toFixed(1),
        monthsSaved,
        yearsSaved: (monthsSaved / 12).toFixed(1),
        totalInterestPaid: totalInterest,
        totalLoanCost: totalPaid,
        totalExtraPaid: totalExtra,
        interestSavings: Math.max(0, totalInterestBase - totalInterest),
      }
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-loan-calculator, [ui-loan-calculator]',
      standalone: true,
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiInputComponent,
        UiSeparatorComponent,
        UiSliderComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableFooterComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
        UiTabsComponent,
        UiTabsListComponent,
        UiTabsTriggerComponent,
      ],
      template: `
        <div data-slot="loan-calculator" [class]="rootClass">
          <!-- Header -->
          <div class="flex flex-col items-center gap-4 text-center">
            <span ui-badge variant="outline" class="gap-1.5 px-3 py-1 text-xs font-medium tracking-wider uppercase">
              <lucide-icon [img]="Calculator" class="text-primary size-3.5" />
              Loan Amortization Engine
            </span>
            <div class="space-y-2">
              <h2 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
                Loan &amp; Mortgage Amortization Calculator
              </h2>
              <p class="text-muted-foreground mx-auto max-w-2xl text-sm sm:text-base">
                Estimate monthly payments, interest vs principal breakdown, and payoff timelines with extra payments.
              </p>
            </div>
    
            <div class="mt-2 flex w-full justify-center">
              <ui-tabs [value]="selectedLoanType()" (valueChange)="handleTabChange($event)" class="w-full sm:w-auto">
                <ui-tabs-list variant="segmented" class="grid w-full grid-cols-2 sm:flex sm:w-auto">
                  <button ui-tabs-trigger value="mortgage-30" class="gap-1.5">
                    <lucide-icon [img]="Home" class="size-3.5" />
                    Mortgage 30Y
                  </button>
                  <button ui-tabs-trigger value="commercial-10" class="gap-1.5">
                    <lucide-icon [img]="Building2" class="size-3.5" />
                    Commercial 10Y
                  </button>
                  <button ui-tabs-trigger value="auto-5" class="gap-1.5">
                    <lucide-icon [img]="Car" class="size-3.5" />
                    Auto Loan 5Y
                  </button>
                  <button ui-tabs-trigger value="personal-3" class="gap-1.5">
                    <lucide-icon [img]="Wallet" class="size-3.5" />
                    Personal Loan
                  </button>
                </ui-tabs-list>
              </ui-tabs>
            </div>
          </div>
    
          <!-- 2-Column Calculator Layout -->
          <div class="grid grid-cols-1 gap-8 lg:grid-cols-12 lg:items-start">
            <div class="space-y-6 lg:col-span-7">
              <div ui-card>
                <div ui-card-header>
                  <h3 ui-card-title class="flex items-center gap-2">
                    <lucide-icon [img]="DollarSign" class="text-primary size-5" />
                    Loan Parameters
                  </h3>
                  <p ui-card-description>
                    Customize loan principal, interest rate, term duration, and extra monthly contributions.
                  </p>
                </div>
                <div ui-card-content class="space-y-8">
                  <div class="space-y-4">
                    <div class="flex items-center justify-between gap-4">
                      <div>
                        <span class="text-foreground text-sm font-medium">Loan Amount (Principal)</span>
                        <p class="text-muted-foreground text-xs">Total borrowed loan amount.</p>
                      </div>
                      <div class="w-36 sm:w-44">
                        <ui-input
                          type="number"
                          [value]="loanAmount()"
                          (valueChange)="loanAmount.set(Number($event) || 0)"
                          placeholder="$450,000"
                          class="text-xs"
                        />
                      </div>
                    </div>
                    <div class="pb-6">
                      <ui-slider
                        [value]="[loanAmount()]"
                        [min]="10000"
                        [max]="2000000"
                        [step]="5000"
                        [marks]="amountMarks"
                        (valueChange)="loanAmount.set($event[0] ?? loanAmount())"
                      />
                    </div>
                    <div class="flex flex-wrap items-center gap-1.5 pt-1">
                      <span class="text-muted-foreground text-xs">Quick select:</span>
                      @for (amt of [150000, 300000, 450000, 750000, 1200000]; track amt) {
                        <button
                          ui-button
                          variant="outline"
                          size="sm"
                          class="h-6 px-2 text-xs"
                          (click)="loanAmount.set(amt)"
                        >
                          {{ amt >= 1000000 ? (amt / 1000000).toFixed(1) + 'M' : amt / 1000 + 'K' }}
                        </button>
                      }
                    </div>
                  </div>
    
                  <div ui-separator></div>
    
                  <div class="space-y-4">
                    <div class="flex items-center justify-between gap-4">
                      <div>
                        <span class="text-foreground text-sm font-medium">Annual Interest Rate (APR)</span>
                        <p class="text-muted-foreground text-xs">Fixed annual percentage rate.</p>
                      </div>
                      <div class="w-32 sm:w-36">
                        <ui-input
                          type="number"
                          [value]="interestRate()"
                          (valueChange)="interestRate.set(Number($event) || 0)"
                          placeholder="6.5%"
                          class="text-xs"
                        />
                      </div>
                    </div>
                    <div class="pb-6">
                      <ui-slider
                        [value]="[interestRate()]"
                        [min]="1"
                        [max]="15"
                        [step]="0.1"
                        [marks]="rateMarks"
                        (valueChange)="interestRate.set($event[0] ?? interestRate())"
                      />
                    </div>
                    <div class="flex flex-wrap items-center gap-1.5 pt-1">
                      <span class="text-muted-foreground text-xs">Market benchmarks:</span>
                      @for (rate of [4.5, 5.5, 6.5, 7.5, 9.5]; track rate) {
                        <button
                          ui-button
                          variant="outline"
                          size="sm"
                          class="h-6 px-2 text-xs"
                          (click)="interestRate.set(rate)"
                        >
                          {{ rate }}%
                        </button>
                      }
                    </div>
                  </div>
    
                  <div ui-separator></div>
    
                  <div class="space-y-4">
                    <div class="flex items-center justify-between gap-4">
                      <div>
                        <span class="text-foreground text-sm font-medium">Loan Term</span>
                        <p class="text-muted-foreground text-xs">Amortization period in years.</p>
                      </div>
                      <div class="flex items-center gap-1.5">
                        <span ui-badge variant="secondary" class="font-mono text-xs font-semibold">
                          {{ loanTermYears() }} Years ({{ loanTermYears() * 12 }} mo)
                        </span>
                      </div>
                    </div>
                    <div class="pb-6">
                      <ui-slider
                        [value]="[loanTermYears()]"
                        [min]="1"
                        [max]="30"
                        [step]="1"
                        [marks]="termMarks"
                        (valueChange)="loanTermYears.set($event[0] ?? loanTermYears())"
                      />
                    </div>
                    <div class="flex flex-wrap items-center gap-1.5 pt-1">
                      <span class="text-muted-foreground text-xs">Standard terms:</span>
                      @for (term of termPresets; track term) {
                        <button
                          ui-button
                          [variant]="loanTermYears() === term ? 'default' : 'outline'"
                          size="sm"
                          class="h-6 px-2 text-xs"
                          (click)="loanTermYears.set(term)"
                        >
                          {{ term }} Yrs
                        </button>
                      }
                    </div>
                  </div>
    
                  <div ui-separator></div>
    
                  <div class="space-y-4">
                    <div class="flex flex-wrap items-center justify-between gap-4">
                      <div>
                        <div class="flex items-center gap-2">
                          <span class="text-foreground text-sm font-medium">Extra Monthly Payment</span>
                          <span
                            ui-badge
                            variant="outline"
                            class="border-success/20 bg-success/10 text-success text-xs font-normal"
                          >
                            Accelerates Payoff
                          </span>
                        </div>
                        <p class="text-muted-foreground text-xs">Directly reduces principal balance each month.</p>
                      </div>
                      <div class="w-36 sm:w-44">
                        <ui-input
                          type="number"
                          [value]="extraMonthlyPayment()"
                          (valueChange)="extraMonthlyPayment.set(Number($event) || 0)"
                          placeholder="$200/mo"
                          class="text-xs"
                        />
                      </div>
                    </div>
                    <div class="pb-6">
                      <ui-slider
                        [value]="[extraMonthlyPayment()]"
                        [min]="0"
                        [max]="2000"
                        [step]="25"
                        [marks]="extraMarks"
                        (valueChange)="extraMonthlyPayment.set($event[0] ?? extraMonthlyPayment())"
                      />
                    </div>
                    <div class="flex flex-wrap items-center gap-1.5 pt-1">
                      <span class="text-muted-foreground text-xs">Quick extra:</span>
                      @for (extra of [0, 100, 200, 500, 1000]; track extra) {
                        <button
                          ui-button
                          variant="outline"
                          size="sm"
                          class="h-6 px-2 text-xs"
                          (click)="extraMonthlyPayment.set(extra)"
                        >
                          +&#36;{{ extra }}/mo
                        </button>
                      }
                    </div>
                  </div>
                </div>
              </div>
            </div>
    
            <!-- Right Results Card -->
            <div class="lg:sticky lg:top-8 lg:col-span-5">
              <div ui-card class="border-border bg-card shadow-xs">
                <div ui-card-header class="pb-4">
                  <div class="flex items-center justify-between">
                    <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                      Estimated Monthly Payment
                    </span>
                    <span
                      ui-badge
                      variant="secondary"
                      class="bg-primary/10 text-primary border-primary/20 text-xs font-semibold"
                    >
                      Amortized
                    </span>
                  </div>
                  <div class="mt-4">
                    <div class="flex items-baseline gap-1.5">
                      <span class="text-foreground text-4xl font-bold tracking-tight tabular-nums sm:text-5xl">
                        {{ fmt(baseMonthlyPayment()) }}
                      </span>
                      <span class="text-muted-foreground text-sm font-normal"> / month</span>
                    </div>
                    @if (extraMonthlyPayment() > 0) {
                      <p class="text-muted-foreground mt-2 text-xs">
                        Total monthly cash outlay:
                        <span class="text-foreground font-semibold tabular-nums">
                          {{ fmt(baseMonthlyPayment() + extraMonthlyPayment()) }}/mo
                        </span>
                        ({{ fmt(baseMonthlyPayment()) }} base + {{ fmt(extraMonthlyPayment()) }} extra)
                      </p>
                    } @else {
                      <p class="text-muted-foreground mt-2 text-xs">
                        Standard fixed payment based on {{ interestRate() }}% APR over {{ loanTermYears() }} years.
                      </p>
                    }
                  </div>
                </div>
    
                <div ui-card-content class="space-y-5">
                  <div ui-separator></div>
    
                  @if (extraMonthlyPayment() > 0 && amortization().interestSavings > 0) {
                    <div class="border-success/20 bg-success/10 text-success rounded-lg border p-3.5 text-xs">
                      <div class="flex items-start gap-2.5">
                        <lucide-icon [img]="PiggyBank" class="mt-0.5 size-4 shrink-0" />
                        <div class="space-y-1">
                          <p class="text-sm leading-snug font-semibold">
                            Saves {{ fmtWhole(amortization().interestSavings) }} and cuts
                            {{ amortization().yearsSaved }} years off payoff!
                          </p>
                          <p class="text-success/80 leading-normal">
                            You'll be debt-free in {{ amortization().actualYears }} years ({{
                              amortization().actualMonths
                            }}
                            payments) instead of {{ loanTermYears() }} years.
                          </p>
                        </div>
                      </div>
                    </div>
                  }
    
                  <div class="space-y-2.5">
                    <div class="flex items-center justify-between text-sm">
                      <span class="text-muted-foreground">Total Loan Principal</span>
                      <span class="font-medium tabular-nums">{{ fmt(loanAmount()) }}</span>
                    </div>
                    <div class="flex items-center justify-between text-sm">
                      <span class="text-muted-foreground">Total Interest Paid</span>
                      <span class="text-warning font-medium tabular-nums">{{ fmt(amortization().totalInterestPaid) }}</span>
                    </div>
                    <div class="flex items-center justify-between text-sm">
                      <span class="text-muted-foreground">Total Loan Cost</span>
                      <span class="text-foreground font-semibold tabular-nums">{{
                        fmt(amortization().totalLoanCost)
                      }}</span>
                    </div>
                    <div class="flex items-center justify-between text-sm">
                      <span class="text-muted-foreground">Effective Payoff Timeline</span>
                      <span class="font-medium tabular-nums">
                        {{ amortization().actualYears }} Years ({{ amortization().actualMonths }} mo)
                      </span>
                    </div>
                  </div>
    
                  <div ui-separator></div>
    
                  <div class="space-y-2.5">
                    <div class="flex items-center justify-between text-xs font-medium">
                      <span class="text-foreground">Principal vs. Total Interest</span>
                      <span class="text-muted-foreground tabular-nums"
                        >{{ principalPercent() }}% / {{ interestPercent() }}%</span
                      >
                    </div>
                    <div class="bg-muted relative flex h-3 w-full overflow-hidden rounded-full shadow-inner">
                      <div
                        class="bg-primary h-full transition-[width] duration-300"
                        [style.width.%]="principalPercent()"
                      ></div>
                      <div
                        class="bg-warning h-full transition-[width] duration-300"
                        [style.width.%]="interestPercent()"
                      ></div>
                    </div>
                    <div
                      class="text-muted-foreground flex flex-col gap-1.5 text-xs sm:flex-row sm:items-center sm:justify-between"
                    >
                      <div class="flex items-center gap-1.5">
                        <span class="bg-primary size-2 shrink-0 rounded-full"></span>
                        <span>Principal: {{ fmtWhole(loanAmount()) }} ({{ principalPercent() }}%)</span>
                      </div>
                      <div class="flex items-center gap-1.5">
                        <span class="bg-warning size-2 shrink-0 rounded-full"></span>
                        <span>Interest: {{ fmtWhole(amortization().totalInterestPaid) }} ({{ interestPercent() }}%)</span>
                      </div>
                    </div>
                  </div>
    
                  <div ui-separator></div>
    
                  <ul class="text-muted-foreground space-y-2 text-xs">
                    <li class="flex items-center gap-2">
                      <lucide-icon [img]="CheckCircle2" class="text-primary size-3.5 shrink-0" />
                      <span>Real-time compound interest calculations</span>
                    </li>
                    <li class="flex items-center gap-2">
                      <lucide-icon [img]="CheckCircle2" class="text-primary size-3.5 shrink-0" />
                      <span>Principal reduction extra payment model</span>
                    </li>
                    <li class="flex items-center gap-2">
                      <lucide-icon [img]="CheckCircle2" class="text-primary size-3.5 shrink-0" />
                      <span>Standard fixed-rate banking amortization schedule</span>
                    </li>
                  </ul>
                </div>
    
                <div ui-card-footer class="flex flex-col gap-2.5 pt-2">
                  <button ui-button class="w-full gap-2 font-semibold shadow-xs" size="lg">
                    Apply for Pre-Approval
                    <lucide-icon [img]="ArrowRight" class="size-4" />
                  </button>
                  <button ui-button aria-label="Download attachment" variant="outline" class="w-full gap-2" size="default">
                    <lucide-icon [img]="FileSpreadsheet" class="size-4" />
                    Download Schedule (CSV)
                  </button>
                </div>
              </div>
            </div>
          </div>
    
          <!-- Amortization Schedule Table -->
          <div ui-card class="mt-8">
            <div ui-card-header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
              <div>
                <h3 ui-card-title class="flex items-center gap-2">
                  <lucide-icon [img]="Calendar" class="text-primary size-5" />
                  Amortization Schedule
                </h3>
                <p ui-card-description>
                  Annual breakdown of principal reduction, interest paid, extra contributions, and ending balance.
                </p>
              </div>
              <div class="flex items-center gap-2">
                <button ui-button variant="outline" size="sm" class="text-xs" (click)="showAllYears.set(!showAllYears())">
                  {{
                    showAllYears() ? 'Show First 5 Years' : 'Show All ' + amortization().yearlySchedule.length + ' Years'
                  }}
                </button>
              </div>
            </div>
            <div ui-card-content>
              <div class="overflow-x-auto">
                <table ui-table density="cozy">
                  <thead ui-table-header>
                    <tr ui-table-row>
                      <th ui-table-head>Year</th>
                      <th ui-table-head class="text-right">Principal Paid</th>
                      <th ui-table-head class="text-right">Interest Paid</th>
                      <th ui-table-head class="text-right">Extra Principal</th>
                      <th ui-table-head class="text-right">Total Payment</th>
                      <th ui-table-head class="text-right">Ending Balance</th>
                    </tr>
                  </thead>
                  <tbody ui-table-body>
                    @for (row of displayedSchedule(); track row.year) {
                      <tr ui-table-row>
                        <td ui-table-cell class="font-medium">
                          <div class="flex items-center gap-2">
                            <span>Year {{ row.year }}</span>
                            @if (row.year === 1) {
                              <span ui-badge variant="outline" class="text-xs font-normal">Initial</span>
                            }
                          </div>
                        </td>
                        <td ui-table-cell class="text-foreground text-right font-medium tabular-nums">
                          {{ fmt(row.principalPaid) }}
                        </td>
                        <td ui-table-cell class="text-warning text-right tabular-nums">{{ fmt(row.interestPaid) }}</td>
                        <td ui-table-cell class="text-success text-right tabular-nums">
                          {{ row.extraPaid > 0 ? '+' + fmt(row.extraPaid) : '—' }}
                        </td>
                        <td ui-table-cell class="text-right font-semibold tabular-nums">{{ fmt(row.totalPaid) }}</td>
                        <td ui-table-cell class="text-right font-semibold tabular-nums">
                          {{ fmt(row.remainingBalance) }}
                        </td>
                      </tr>
                    }
                  </tbody>
                  <tfoot ui-table-footer>
                    <tr ui-table-row>
                      <td ui-table-cell class="font-bold">
                        Total ({{ displayedSchedule().length }} {{ displayedSchedule().length === 1 ? 'Year' : 'Years' }})
                      </td>
                      <td ui-table-cell class="text-foreground text-right font-bold tabular-nums">
                        {{ fmt(displayedTotals().principal) }}
                      </td>
                      <td ui-table-cell class="text-warning text-right font-bold tabular-nums">
                        {{ fmt(displayedTotals().interest) }}
                      </td>
                      <td ui-table-cell class="text-success text-right font-bold tabular-nums">
                        {{ fmt(displayedTotals().extra) }}
                      </td>
                      <td ui-table-cell class="text-right font-bold tabular-nums">{{ fmt(displayedTotals().total) }}</td>
                      <td ui-table-cell class="text-right font-bold tabular-nums">
                        {{ fmt(displayedSchedule()[displayedSchedule().length - 1]?.remainingBalance ?? 0) }}
                      </td>
                    </tr>
                  </tfoot>
                </table>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiLoanCalculatorComponent {
      protected readonly ArrowRight = ArrowRight
      protected readonly Building2 = Building2
      protected readonly Calculator = Calculator
      protected readonly Calendar = Calendar
      protected readonly Car = Car
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly DollarSign = DollarSign
      protected readonly FileSpreadsheet = FileSpreadsheet
      protected readonly Home = Home
      protected readonly PiggyBank = PiggyBank
      protected readonly Wallet = Wallet
    
      protected readonly amountMarks = LOAN_AMOUNT_MARKS
      protected readonly rateMarks = INTEREST_RATE_MARKS
      protected readonly termMarks = LOAN_TERM_MARKS
      protected readonly extraMarks = EXTRA_PAYMENT_MARKS
      protected readonly termPresets = TERM_PRESETS
    
      @Input('class') className?: string
    
      readonly selectedLoanType = signal<LoanType>('mortgage-30')
      readonly loanAmount = signal(450_000)
      readonly interestRate = signal(6.5)
      readonly loanTermYears = signal(30)
      readonly extraMonthlyPayment = signal(200)
      readonly showAllYears = signal(false)
    
      readonly baseMonthlyPayment = computed(() =>
        baseMonthlyPaymentFor(this.loanAmount(), this.interestRate(), this.loanTermYears()),
      )
      readonly totalCostBase = computed(() => this.baseMonthlyPayment() * this.loanTermYears() * 12)
      readonly totalInterestBase = computed(() => Math.max(0, this.totalCostBase() - this.loanAmount()))
    
      readonly amortization = computed(() =>
        amortize(
          this.loanAmount(),
          this.interestRate(),
          this.loanTermYears(),
          this.extraMonthlyPayment(),
          this.baseMonthlyPayment(),
          this.totalInterestBase(),
        ),
      )
    
      readonly principalPercent = computed(() => {
        const total = this.amortization().totalLoanCost
        if (total <= 0) return 100
        return Math.min(100, Math.max(0, Math.round((this.loanAmount() / total) * 100)))
      })
      readonly interestPercent = computed(() => Math.max(0, 100 - this.principalPercent()))
    
      readonly displayedSchedule = computed(() =>
        this.showAllYears() ? this.amortization().yearlySchedule : this.amortization().yearlySchedule.slice(0, 5),
      )
      readonly displayedTotals = computed(() => {
        const list = this.displayedSchedule()
        return {
          principal: list.reduce((s, r) => s + r.principalPaid, 0),
          interest: list.reduce((s, r) => s + r.interestPaid, 0),
          extra: list.reduce((s, r) => s + r.extraPaid, 0),
          total: list.reduce((s, r) => s + r.totalPaid, 0),
        }
      })
    
      get rootClass(): string {
        return cn('mx-auto w-full max-w-6xl space-y-10 p-4 sm:p-6 lg:p-8', this.className)
      }
    
      fmt(v: number): string {
        return fmtCurrency(v)
      }
    
      fmtWhole(v: number): string {
        return fmtCurrencyWhole(v)
      }
    
      handleTabChange(value: string): void {
        const type = value as LoanType
        this.selectedLoanType.set(type)
        const preset = LOAN_PRESETS[type]
        if (preset) {
          this.loanAmount.set(preset.amount)
          this.interestRate.set(preset.rate)
          this.loanTermYears.set(preset.termYears)
          this.extraMonthlyPayment.set(preset.extraPayment)
        }
      }
    }
    
  • src/app/components/blocks/loan-calculator/index.ts0.4 kB

Raw manifest:https://uipkge.dev/r/angular/loan-calculator.json