Invoice Creator Wizard
blockfinanceSquare/Stripe-style interactive invoice builder with itemized tax, discount calculation, dynamic line items, client presets, and a real-time paper document preview.
Installation
$pnpm dlx uipkge-ng@latest add invoice-creator-wizard$npx uipkge-ng@latest add invoice-creator-wizard$yarn dlx uipkge-ng@latest add invoice-creator-wizard$bunx uipkge-ng@latest add invoice-creator-wizardManifest:
https://uipkge.dev/r/angular/invoice-creator-wizard.jsonInstalls to:src/app/components/blocks/invoice-creator-wizard/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
InvoiceLineIteminterface InvoiceLineItem {
id: string
description: string
quantity: number
unitPrice: number
}ClientPresetinterface ClientPreset {
id: string
name: string
company: string
email: string
address: string
}Files installed (2)
src/app/components/blocks/invoice-creator-wizard/invoice-creator-wizard.component.ts41.8 kB
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, computed, signal } from '@angular/core' import { Building2, CheckCircle2, CreditCard, Eye, FileText, LucideAngularModule, Percent, Plus, Receipt, Save, Send, Trash2, } 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 { UiInputComponent } from '@/ui/input/input.component' import { UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, } from '@/ui/select/select.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' import { UiTextareaComponent } from '@/ui/textarea/textarea.component' export interface InvoiceLineItem { id: string description: string quantity: number unitPrice: number } export interface ClientPreset { id: string name: string company: string email: string address: string } const CLIENT_PRESETS: ClientPreset[] = [ { id: 'c1', name: 'Sarah Jenkins', company: 'Apex Digital Inc.', email: 'sarah.jenkins@apexdigital.com', address: '742 Evergreen Terrace, Springfield, OR 97477', }, { id: 'c2', name: 'Marcus Vance', company: 'Northwind Global Corp.', email: 'marcus.vance@northwind.io', address: '452 Broadway, 8th Floor, New York, NY 10013', }, { id: 'c3', name: 'Elena Rostova', company: 'Hyperion Robotics', email: 'elena@hyperion-robotics.ai', address: '10 Tech Parkway, Suite 300, Boston, MA 02115', }, ] const DEFAULT_LINE_ITEMS: InvoiceLineItem[] = [ { id: 'item-1', description: 'Design System Architecture & Component Registry', quantity: 1, unitPrice: 3200 }, { id: 'item-2', description: 'Frontend Implementation (Vue 3 + React Mirror)', quantity: 35, unitPrice: 120 }, { id: 'item-3', description: 'Accessibility & WCAG AA Compliance Audit', quantity: 1, unitPrice: 850 }, ] const PAYMENT_TERMS_MAP: Record<string, string> = { receipt: 'Due on Receipt', net15: 'Net 15 (Due in 15 days)', net30: 'Net 30 (Due in 30 days)', net60: 'Net 60 (Due in 60 days)', } /** * InvoiceCreatorWizard -- the React block, 1:1: a Square/Stripe-style interactive invoice * builder with a business/client details card, client presets, invoice timing + currency * selects, a dynamic line-items table, tax/discount adjustments, notes, a live paper * document preview, and Preview PDF / Save Draft / Send Invoice actions with a banner. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-invoice-creator-wizard, [ui-invoice-creator-wizard]', 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, UiInputComponent, UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, UiSeparatorComponent, UiTextareaComponent, ], template: ` <div data-slot="invoice-creator-wizard" [class]="rootClass"> <!-- Top Action Header --> <div class="border-border flex flex-col gap-4 border-b pb-5 sm:flex-row sm:items-center sm:justify-between"> <div class="space-y-1"> <div class="flex items-center gap-3"> <h1 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">Create New Invoice</h1> <span ui-badge wrap variant="outline" class="px-2 py-0.5 font-mono text-xs font-semibold"> {{ invoiceNumber() }} </span> <span ui-badge wrap variant="secondary" class="text-xs">Draft</span> </div> <p class="text-muted-foreground text-xs sm:text-sm"> Build itemized invoices with live calculation, instant document preview, and client presets. </p> </div> <!-- Action Buttons --> <div class="flex flex-wrap items-center gap-2"> <button ui-button variant="outline" size="sm" class="gap-1.5 text-xs" (click)="handlePreviewPdf()"> <lucide-icon [img]="FileText" class="size-3.5" /> Preview PDF </button> <button ui-button variant="outline" size="sm" class="gap-1.5 text-xs" (click)="handleSaveDraft()"> <lucide-icon [img]="Save" class="size-3.5" /> Save Draft </button> <button ui-button size="sm" class="gap-1.5 text-xs font-medium" (click)="handleSendInvoice()"> <lucide-icon [img]="Send" class="size-3.5" /> Send Invoice </button> </div> </div> <!-- Notification Toast / Alert Banner --> @if (notification(); as note) { <div [class]=" cn( 'flex flex-wrap items-center justify-between rounded-lg border px-4 py-2.5 text-xs transition-colors sm:text-sm', note.type === 'success' ? 'border-success/30 bg-success/10 text-success' : 'border-primary/30 bg-primary/10 text-primary' ) " role="status" > <div class="flex flex-wrap items-center gap-2"> <lucide-icon [img]="CheckCircle2" class="size-4 shrink-0" /> <span>{{ note.message }}</span> </div> <button type="button" class="min-h-6 text-xs font-semibold underline-offset-2 opacity-80 hover:underline hover:opacity-100" (click)="notification.set(null)" > Dismiss </button> </div> } <!-- 2-Column Invoice Builder Layout --> <div class="grid grid-cols-1 items-start gap-8 lg:grid-cols-12"> <!-- Left Column: Form Builder --> <div class="space-y-6 lg:col-span-7"> <!-- Section 1: Business & Client Details --> <div ui-card> <div ui-card-header class="pb-4"> <div class="flex flex-wrap items-center gap-2"> <lucide-icon [img]="Building2" class="text-primary size-4" /> <h3 ui-card-title class="text-base font-semibold">Business & Client Details</h3> </div> <p ui-card-description class="text-xs"> Configure your issuing entity information and bill-to client contact. </p> </div> <div ui-card-content class="space-y-5"> <!-- Issuer Details --> <div class="space-y-3"> <div class="flex flex-wrap items-center justify-between"> <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase" >From (Issuer)</span > <span class="text-muted-foreground font-mono text-xs">ID: {{ invoiceNumber() }}</span> </div> <div class="grid gap-3 sm:grid-cols-2"> <div class="space-y-1.5 sm:col-span-2"> <label for="icn-issuer-name" class="text-foreground text-xs font-medium"> Business / Organization Name </label> <ui-input id="icn-issuer-name" [value]="issuerName()" (valueChange)="issuerName.set($event)" placeholder="Business name" size="small" /> </div> <div class="space-y-1.5"> <label for="icn-issuer-email" class="text-foreground text-xs font-medium">Billing Email</label> <ui-input id="icn-issuer-email" [value]="issuerEmail()" (valueChange)="issuerEmail.set($event)" type="email" placeholder="billing@company.com" size="small" /> </div> <div class="space-y-1.5"> <label for="icn-issuer-tax" class="text-foreground text-xs font-medium" >Tax ID / VAT Registration</label > <ui-input id="icn-issuer-tax" [value]="issuerTaxId()" (valueChange)="issuerTaxId.set($event)" placeholder="Tax ID / EIN" size="small" /> </div> <div class="space-y-1.5 sm:col-span-2"> <label for="icn-issuer-address" class="text-foreground text-xs font-medium">Business Address</label> <ui-input id="icn-issuer-address" [value]="issuerAddress()" (valueChange)="issuerAddress.set($event)" placeholder="Street, City, State, ZIP" size="small" /> </div> </div> </div> <div ui-separator></div> <!-- Client Details --> <div class="space-y-3"> <div class="flex flex-wrap items-center justify-between"> <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase" >Bill To (Client)</span > <div class="flex flex-wrap items-center gap-2"> <span class="text-muted-foreground text-xs">Quick Preset:</span> <div class="w-48"> <ui-select [value]="selectedPreset()" (valueChange)="handlePresetChange($event)"> <button ui-select-trigger class="h-7 text-xs"> <ui-select-value placeholder="Select client" /> </button> <ui-select-content> @for (preset of clientPresets; track preset.id) { <ui-select-item [value]="preset.id">{{ preset.company }}</ui-select-item> } <ui-select-item value="custom">Custom Client</ui-select-item> </ui-select-content> </ui-select> </div> </div> </div> <div class="grid gap-3 sm:grid-cols-2"> <div class="space-y-1.5"> <label for="icn-client-name" class="text-foreground text-xs font-medium">Contact Name</label> <ui-input id="icn-client-name" [value]="clientName()" (valueChange)="clientName.set($event)" placeholder="Client contact name" size="small" /> </div> <div class="space-y-1.5"> <label for="icn-client-company" class="text-foreground text-xs font-medium">Company Name</label> <ui-input id="icn-client-company" [value]="clientCompany()" (valueChange)="clientCompany.set($event)" placeholder="Client company name" size="small" /> </div> <div class="space-y-1.5"> <label for="icn-client-email" class="text-foreground text-xs font-medium">Client Email</label> <ui-input id="icn-client-email" [value]="clientEmail()" (valueChange)="clientEmail.set($event)" type="email" placeholder="client@company.com" size="small" /> </div> <div class="space-y-1.5"> <label for="icn-client-address" class="text-foreground text-xs font-medium">Client Address</label> <ui-input id="icn-client-address" [value]="clientAddress()" (valueChange)="clientAddress.set($event)" placeholder="Street, City, Postal Code" size="small" /> </div> </div> </div> <div ui-separator></div> <!-- Invoice Timing & Currency --> <div class="grid gap-3 sm:grid-cols-4"> <div class="space-y-1.5"> <label for="icn-invoice-date" class="text-foreground text-xs font-medium">Invoice Date</label> <ui-input id="icn-invoice-date" [value]="invoiceDate()" (valueChange)="invoiceDate.set($event)" type="date" size="small" /> </div> <div class="space-y-1.5"> <label for="icn-due-date" class="text-foreground text-xs font-medium">Due Date</label> <ui-input id="icn-due-date" [value]="dueDate()" (valueChange)="dueDate.set($event)" type="date" size="small" /> </div> <div class="space-y-1.5"> <label for="icn-payment-terms" class="text-foreground text-xs font-medium">Payment Terms</label> <ui-select [value]="paymentTerms()" (valueChange)="paymentTerms.set($event)"> <button ui-select-trigger id="icn-payment-terms" class="h-8 text-xs"> <ui-select-value placeholder="Terms" /> </button> <ui-select-content> <ui-select-item value="receipt">Due on Receipt</ui-select-item> <ui-select-item value="net15">Net 15</ui-select-item> <ui-select-item value="net30">Net 30</ui-select-item> <ui-select-item value="net60">Net 60</ui-select-item> </ui-select-content> </ui-select> </div> <div class="space-y-1.5"> <label for="icn-currency" class="text-foreground text-xs font-medium">Currency</label> <ui-select [value]="currency()" (valueChange)="currency.set($event)"> <button ui-select-trigger id="icn-currency" class="h-8 text-xs"> <ui-select-value placeholder="Currency" /> </button> <ui-select-content> <ui-select-item value="USD">USD ($)</ui-select-item> <ui-select-item value="EUR">EUR (€)</ui-select-item> <ui-select-item value="GBP">GBP (£)</ui-select-item> <ui-select-item value="CAD">CAD ($)</ui-select-item> <ui-select-item value="AUD">AUD ($)</ui-select-item> </ui-select-content> </ui-select> </div> </div> </div> </div> <!-- Section 2: Interactive Line Items --> <div ui-card> <div ui-card-header class="pb-3"> <div class="flex flex-wrap items-center justify-between"> <div class="flex flex-wrap items-center gap-2"> <lucide-icon [img]="Receipt" class="text-primary size-4" /> <h3 ui-card-title class="text-base font-semibold">Line Items</h3> </div> <span ui-badge wrap variant="secondary" class="font-mono text-xs tabular-nums"> {{ items().length }} {{ items().length === 1 ? 'item' : 'items' }} </span> </div> <p ui-card-description class="text-xs"> Itemize deliverables, billable hours, software licenses, or custom services. </p> </div> <div ui-card-content class="space-y-4"> <div class="overflow-x-auto"> <table class="w-full text-left text-xs"> <thead> <tr class="border-border text-muted-foreground border-b"> <th class="pb-2 font-medium">Description</th> <th class="w-20 pb-2 text-right font-medium">Qty</th> <th class="w-28 pb-2 text-right font-medium">Unit Price</th> <th class="w-24 pb-2 text-right font-medium">Line Total</th> <th class="w-10 pb-2 text-center font-medium"></th> </tr> </thead> <tbody class="divide-border/60 divide-y"> @for (item of items(); track item.id) { <tr class="group"> <td class="py-2.5 pr-2"> <ui-input [value]="item.description" (valueChange)="updateItem(item.id, { description: $event })" placeholder="Service or product description..." size="small" class="w-full" /> </td> <td class="px-2 py-2.5"> <ui-input [value]="item.quantity" (valueChange)="updateItem(item.id, { quantity: toNumber($event) })" type="number" size="small" class="w-20 text-right tabular-nums" /> </td> <td class="px-2 py-2.5"> <ui-input [value]="item.unitPrice" (valueChange)="updateItem(item.id, { unitPrice: toNumber($event) })" type="number" size="small" class="w-28 text-right tabular-nums" /> </td> <td class="text-foreground py-2.5 pl-2 text-right font-medium whitespace-nowrap tabular-nums"> {{ formatCurrency((toNum(item.quantity) || 0) * (toNum(item.unitPrice) || 0)) }} </td> <td class="py-2.5 pl-1 text-center"> <button type="button" ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-destructive size-7 disabled:opacity-30" [disabled]="items().length <= 1" aria-label="Remove item" (click)="removeLineItem(item.id)" > <lucide-icon [img]="Trash2" class="size-3.5" /> </button> </td> </tr> } </tbody> </table> </div> <div class="flex flex-wrap items-center justify-between pt-2"> <button type="button" ui-button variant="outline" size="sm" class="gap-1 text-xs" (click)="addLineItem()" > <lucide-icon [img]="Plus" class="size-3.5" /> Add Line Item </button> <div class="text-muted-foreground text-xs"> Subtotal: <span class="text-foreground font-semibold tabular-nums">{{ formatCurrency(subtotal()) }}</span> </div> </div> </div> </div> <!-- Section 3: Adjustments & Payment Terms --> <div ui-card> <div ui-card-header class="pb-3"> <div class="flex flex-wrap items-center gap-2"> <lucide-icon [img]="Percent" class="text-primary size-4" /> <h3 ui-card-title class="text-base font-semibold">Adjustments & Notes</h3> </div> <p ui-card-description class="text-xs"> Itemize tax rates, discount subtractions, and client payment instructions. </p> </div> <div ui-card-content class="space-y-4"> <div class="grid gap-4 sm:grid-cols-2"> <!-- Tax percentage --> <div class="space-y-1.5"> <div class="flex flex-wrap items-center justify-between"> <label for="icn-tax-rate" class="text-foreground text-xs font-medium">Tax Rate (%)</label> <span class="text-muted-foreground text-xs tabular-nums">+{{ formatCurrency(taxAmount()) }}</span> </div> <ui-input id="icn-tax-rate" [value]="taxRate()" (valueChange)="taxRate.set(toNumber($event))" type="number" placeholder="8.5" size="small" /> </div> <!-- Discount --> <div class="space-y-1.5"> <div class="flex flex-wrap items-center justify-between"> <label for="icn-discount-val" class="text-foreground text-xs font-medium">Discount</label> <div class="flex items-center gap-1"> <button type="button" [class]=" cn( 'min-h-6 rounded px-1.5 py-0.5 text-xs font-medium transition-colors', discountType() === 'fixed' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground' ) " (click)="discountType.set('fixed')" > Fixed ($) </button> <button type="button" [class]=" cn( 'min-h-6 rounded px-1.5 py-0.5 text-xs font-medium transition-colors', discountType() === 'percent' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground' ) " (click)="discountType.set('percent')" > Percent (%) </button> </div> </div> <div class="flex flex-wrap items-center gap-2"> <ui-input id="icn-discount-val" [value]="discountValue()" (valueChange)="discountValue.set(toNumber($event))" type="number" placeholder="0" size="small" class="w-full" /> <span class="text-success text-success text-xs font-medium whitespace-nowrap tabular-nums"> -{{ formatCurrency(effectiveDiscount()) }} </span> </div> </div> </div> <!-- Notes / Terms --> <div class="space-y-1.5"> <label for="icn-notes" class="text-foreground text-xs font-medium">Notes & Terms for Client</label> <ui-textarea id="icn-notes" [value]="notes()" (valueChange)="notes.set($event)" [rows]="3" placeholder="Payment instructions, bank wire info, or a personal thank you note..." class="text-xs" /> </div> </div> </div> </div> <!-- Right Column: Live Paper Document Preview --> <div class="space-y-4 lg:sticky lg:top-6 lg:col-span-5"> <div class="flex flex-wrap items-center justify-between px-1"> <div class="flex flex-wrap items-center gap-2"> <span class="relative flex size-2"> <span class="bg-success absolute inline-flex h-full w-full rounded-full opacity-75"></span> <span class="bg-success relative inline-flex size-2 rounded-full"></span> </span> <span class="text-muted-foreground flex items-center gap-1 text-xs font-medium"> <lucide-icon [img]="Eye" class="size-3.5" /> Live Document Preview </span> </div> <span class="text-muted-foreground font-mono text-xs">Printable A4</span> </div> <!-- Paper Invoice Card --> <div class="bg-card text-card-foreground border-border/80 relative space-y-6 overflow-hidden rounded-xl border p-6 shadow-md transition-colors sm:p-7" > <!-- Document Header --> <div class="flex items-start justify-between gap-4"> <div class="space-y-1"> <div class="flex flex-wrap items-center gap-2"> <div class="bg-primary/10 text-primary border-primary/20 flex size-8 items-center justify-center rounded border text-xs font-bold" > AS </div> <div class="text-foreground text-sm leading-tight font-bold sm:text-base"> {{ issuerName() || 'Business Name' }} </div> </div> <p class="text-muted-foreground max-w-[200px] text-xs leading-relaxed"> {{ issuerAddress() || 'Address not set' }} </p> <p class="text-muted-foreground font-mono text-xs"> {{ issuerEmail() || 'billing@domain.com' }} </p> @if (issuerTaxId()) { <p class="text-muted-foreground/80 font-mono text-xs">Tax: {{ issuerTaxId() }}</p> } </div> <div class="shrink-0 space-y-1 text-right"> <span class="text-muted-foreground text-xs font-bold tracking-wider uppercase">Invoice</span> <div class="text-foreground font-mono text-xs font-bold sm:text-sm">{{ invoiceNumber() }}</div> <span ui-badge wrap variant="outline" class="border-success/30 bg-success/10 text-success text-xs font-medium" > Ready to Send </span> </div> </div> <div ui-separator class="opacity-60"></div> <!-- Invoice Details: Bill To & Dates --> <div class="grid grid-cols-2 gap-4 text-xs"> <div class="space-y-1"> <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">Billed To</span> <p class="text-foreground font-semibold">{{ clientName() || 'Client Name' }}</p> @if (clientCompany()) { <p class="text-muted-foreground font-medium">{{ clientCompany() }}</p> } <p class="text-muted-foreground leading-relaxed">{{ clientAddress() || 'No address provided' }}</p> <p class="text-muted-foreground font-mono">{{ clientEmail() || 'client@email.com' }}</p> </div> <div class="space-y-1 text-right"> <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase" >Invoice Details</span > <div class="space-y-0.5"> <div class="text-muted-foreground flex justify-between gap-2 sm:justify-end"> <span>Issued:</span> <span class="text-foreground font-medium tabular-nums">{{ formatDate(invoiceDate()) }}</span> </div> <div class="text-muted-foreground flex justify-between gap-2 sm:justify-end"> <span>Due:</span> <span class="text-foreground font-medium tabular-nums">{{ formatDate(dueDate()) }}</span> </div> <div class="text-muted-foreground flex justify-between gap-2 sm:justify-end"> <span>Terms:</span> <span class="text-foreground font-medium">{{ termsLabel() }}</span> </div> </div> </div> </div> <div ui-separator class="opacity-60"></div> <!-- Line Items Table --> <div class="space-y-2"> <div class="overflow-x-auto"> <table class="w-full text-xs"> <thead> <tr class="border-border/80 text-muted-foreground border-b font-medium"> <th class="pb-2 text-left">Item & Description</th> <th class="w-12 pb-2 text-center">Qty</th> <th class="w-16 pb-2 text-right">Rate</th> <th class="w-20 pb-2 text-right">Amount</th> </tr> </thead> <tbody class="divide-border/40 divide-y"> @for (item of items(); track item.id) { <tr class="text-foreground"> <td class="py-2 pr-2"> <p class="leading-snug font-medium">{{ item.description || 'Custom Deliverable' }}</p> </td> <td class="text-muted-foreground px-1 py-2 text-center tabular-nums">{{ item.quantity }}</td> <td class="text-muted-foreground px-1 py-2 text-right tabular-nums"> {{ formatCurrency(toNum(item.unitPrice) || 0) }} </td> <td class="py-2 pl-2 text-right font-medium tabular-nums"> {{ formatCurrency((toNum(item.quantity) || 0) * (toNum(item.unitPrice) || 0)) }} </td> </tr> } </tbody> </table> </div> </div> <div ui-separator class="opacity-60"></div> <!-- Calculations Summary --> <div class="space-y-2 text-xs"> <div class="text-muted-foreground flex justify-between"> <span>Subtotal</span> <span class="text-foreground font-medium tabular-nums">{{ formatCurrency(subtotal()) }}</span> </div> @if (effectiveDiscount() > 0) { <div class="text-success flex justify-between font-medium"> <span> Discount @if (discountType() === 'percent') { ({{ discountValue() }}%) } </span> <span class="tabular-nums">-{{ formatCurrency(effectiveDiscount()) }}</span> </div> } @if (taxRate() > 0) { <div class="text-muted-foreground flex justify-between"> <span>Tax ({{ taxRate() }}%)</span> <span class="text-foreground font-medium tabular-nums">+{{ formatCurrency(taxAmount()) }}</span> </div> } <div ui-separator class="my-1.5 opacity-80"></div> <div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5 pt-1"> <span class="text-foreground text-sm font-bold">Total Due</span> <span class="text-foreground text-lg font-bold tabular-nums">{{ formatCurrency(totalDue()) }}</span> </div> </div> <!-- Notes / Payment Info Callout --> <div class="bg-muted/50 border-border/70 space-y-2 rounded-lg border p-3.5 text-xs"> <div class="text-foreground flex items-center gap-1.5 font-medium"> <lucide-icon [img]="CreditCard" class="text-primary size-3.5" /> <span>Payment Instructions</span> </div> @if (notes()) { <p class="text-muted-foreground leading-relaxed">{{ notes() }}</p> } <div class="border-border/50 text-muted-foreground flex flex-wrap items-center justify-between gap-2 border-t pt-1.5 font-mono text-xs" > <span>ACH / Wire: **** 9104</span> <span>Routing: **** 4892</span> </div> </div> </div> </div> </div> </div> `, }) export class UiInvoiceCreatorWizardComponent implements OnInit { protected readonly Building2 = Building2 protected readonly CheckCircle2 = CheckCircle2 protected readonly CreditCard = CreditCard protected readonly Eye = Eye protected readonly FileText = FileText protected readonly Percent = Percent protected readonly Plus = Plus protected readonly Receipt = Receipt protected readonly Save = Save protected readonly Send = Send protected readonly Trash2 = Trash2 protected readonly clientPresets = CLIENT_PRESETS protected readonly cn = cn @Input() initialInvoiceNumber = 'INV-2026-0042' @Input() initialIssuerName = 'Acme Design & Engineering Studio' @Input() initialIssuerEmail = 'billing@acmestudio.io' @Input() initialIssuerAddress = '100 Montgomery St, Suite 1400, San Francisco, CA 94104' @Input() initialIssuerTaxId = 'US-EIN-94-2819034' @Input() initialClientName = 'Sarah Jenkins' @Input() initialClientCompany = 'Apex Digital Inc.' @Input() initialClientEmail = 'sarah.jenkins@apexdigital.com' @Input() initialClientAddress = '742 Evergreen Terrace, Springfield, OR 97477' @Input() initialInvoiceDate = '2026-08-21' @Input() initialDueDate = '2026-09-20' @Input() initialPaymentTerms = 'net30' @Input() initialCurrency = 'USD' @Input() initialItems?: InvoiceLineItem[] @Input() initialTaxRate = 8.5 @Input() initialDiscountType: 'fixed' | 'percent' = 'fixed' @Input() initialDiscountValue = 150 @Input() initialNotes = 'Thank you for your partnership! Please remit payment via wire transfer or online payment link within 30 days. Contact billing@acmestudio.io for any questions.' @Input('class') className?: string @Output() readonly saveDraft = new EventEmitter<Record<string, unknown>>() @Output() readonly sendInvoice = new EventEmitter<Record<string, unknown>>() @Output() readonly previewPdf = new EventEmitter<void>() readonly invoiceNumber = signal('INV-2026-0042') readonly issuerName = signal('Acme Design & Engineering Studio') readonly issuerEmail = signal('billing@acmestudio.io') readonly issuerAddress = signal('100 Montgomery St, Suite 1400, San Francisco, CA 94104') readonly issuerTaxId = signal('US-EIN-94-2819034') readonly selectedPreset = signal('c1') readonly clientName = signal('Sarah Jenkins') readonly clientCompany = signal('Apex Digital Inc.') readonly clientEmail = signal('sarah.jenkins@apexdigital.com') readonly clientAddress = signal('742 Evergreen Terrace, Springfield, OR 97477') readonly invoiceDate = signal('2026-08-21') readonly dueDate = signal('2026-09-20') readonly paymentTerms = signal('net30') readonly currency = signal('USD') readonly items = signal<InvoiceLineItem[]>(DEFAULT_LINE_ITEMS.map((i) => ({ ...i }))) readonly taxRate = signal(8.5) readonly discountType = signal<'fixed' | 'percent'>('fixed') readonly discountValue = signal(150) readonly notes = signal( 'Thank you for your partnership! Please remit payment via wire transfer or online payment link within 30 days. Contact billing@acmestudio.io for any questions.', ) readonly notification = signal<{ type: 'success' | 'info'; message: string } | null>(null) private notifTimeout: ReturnType<typeof setTimeout> | null = null ngOnInit(): void { this.invoiceNumber.set(this.initialInvoiceNumber) this.issuerName.set(this.initialIssuerName) this.issuerEmail.set(this.initialIssuerEmail) this.issuerAddress.set(this.initialIssuerAddress) this.issuerTaxId.set(this.initialIssuerTaxId) this.clientName.set(this.initialClientName) this.clientCompany.set(this.initialClientCompany) this.clientEmail.set(this.initialClientEmail) this.clientAddress.set(this.initialClientAddress) this.invoiceDate.set(this.initialInvoiceDate) this.dueDate.set(this.initialDueDate) this.paymentTerms.set(this.initialPaymentTerms) this.currency.set(this.initialCurrency) this.items.set( this.initialItems && this.initialItems.length > 0 ? this.initialItems.map((i) => ({ ...i })) : DEFAULT_LINE_ITEMS.map((i) => ({ ...i })), ) this.taxRate.set(this.initialTaxRate) this.discountType.set(this.initialDiscountType) this.discountValue.set(this.initialDiscountValue) this.notes.set(this.initialNotes) } readonly subtotal = computed(() => { return this.items().reduce((acc, item) => { const qty = Number(item.quantity) || 0 const price = Number(item.unitPrice) || 0 return acc + qty * price }, 0) }) readonly discountAmount = computed(() => { const val = Number(this.discountValue()) || 0 if (this.discountType() === 'percent') return this.subtotal() * (val / 100) return val }) readonly effectiveDiscount = computed(() => { return Math.min(this.subtotal(), Math.max(0, this.discountAmount())) }) readonly taxableAmount = computed(() => Math.max(0, this.subtotal() - this.effectiveDiscount())) readonly taxAmount = computed(() => { const rate = Number(this.taxRate()) || 0 return this.taxableAmount() * (rate / 100) }) readonly totalDue = computed(() => this.taxableAmount() + this.taxAmount()) get rootClass(): string { return cn('mx-auto w-full max-w-7xl space-y-6', this.className) } toNumber(raw: string): number { return Number(raw) || 0 } toNum(value: number | string): number { return Number(value) || 0 } termsLabel(): string { return PAYMENT_TERMS_MAP[this.paymentTerms()] ?? this.paymentTerms() } formatCurrency(amount: number, curr: string = this.currency()): string { try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: curr, minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(amount) } catch { const symbols: Record<string, string> = { USD: '$', EUR: '€', GBP: '£', CAD: 'CA$', AUD: 'AU$' } const s = symbols[curr] || '$' return `${s}${amount.toFixed(2)}` } } formatDate(dateStr: string): string { if (!dateStr) return '—' try { const [y, m, d] = dateStr.split('-').map(Number) if (!y || !m || !d) return dateStr const date = new Date(y, m - 1, d) return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) } catch { return dateStr } } private showNotification(message: string, type: 'success' | 'info' = 'success'): void { if (this.notifTimeout) clearTimeout(this.notifTimeout) this.notification.set({ message, type }) this.notifTimeout = setTimeout(() => this.notification.set(null), 4000) } handlePresetChange(presetId: string): void { const id = String(presetId) this.selectedPreset.set(id) if (id === 'custom') return const preset = CLIENT_PRESETS.find((c) => c.id === id) if (preset) { this.clientName.set(preset.name) this.clientCompany.set(preset.company) this.clientEmail.set(preset.email) this.clientAddress.set(preset.address) } } addLineItem(): void { const newId = `item-${Date.now()}` this.items.update((prev) => [...prev, { id: newId, description: '', quantity: 1, unitPrice: 0 }]) } removeLineItem(id: string): void { if (this.items().length > 1) this.items.update((prev) => prev.filter((i) => i.id !== id)) } updateItem(id: string, patch: Partial<InvoiceLineItem>): void { this.items.update((prev) => prev.map((i) => (i.id === id ? { ...i, ...patch } : i))) } getPayload(): Record<string, unknown> { return { invoiceNumber: this.invoiceNumber(), issuer: { name: this.issuerName(), email: this.issuerEmail(), address: this.issuerAddress(), taxId: this.issuerTaxId(), }, client: { name: this.clientName(), company: this.clientCompany(), email: this.clientEmail(), address: this.clientAddress(), }, dates: { issued: this.invoiceDate(), due: this.dueDate(), terms: this.paymentTerms(), }, currency: this.currency(), items: this.items(), calculations: { subtotal: this.subtotal(), discount: this.effectiveDiscount(), tax: this.taxAmount(), total: this.totalDue(), }, notes: this.notes(), } } handleSaveDraft(): void { this.saveDraft.emit(this.getPayload()) this.showNotification(`Draft invoice ${this.invoiceNumber()} saved successfully.`, 'info') } handleSendInvoice(): void { this.sendInvoice.emit(this.getPayload()) this.showNotification(`Invoice ${this.invoiceNumber()} sent to ${this.clientEmail() || 'client'}.`, 'success') } handlePreviewPdf(): void { this.previewPdf.emit() this.showNotification(`Generating printable PDF preview for ${this.invoiceNumber()}...`, 'info') } }src/app/components/blocks/invoice-creator-wizard/index.ts0.1 kB
export { UiInvoiceCreatorWizardComponent, type ClientPreset, type InvoiceLineItem, } from './invoice-creator-wizard.component'
Raw manifest:https://uipkge.dev/r/angular/invoice-creator-wizard.json