Menu

Change language

GitHub repo

Invoice Creator Wizard

blockfinance

Square/Stripe-style interactive invoice builder with itemized tax, discount calculation, dynamic line items, client presets, and a real-time paper document preview.

Installation

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

Variants

Loading interactive previews…

Schema

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

InvoiceLineItem
interface InvoiceLineItem {
  id: string
  description: string
  quantity: number
  unitPrice: number
}
ClientPreset
interface 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: '[email protected]',
        address: '742 Evergreen Terrace, Springfield, OR 97477',
      },
      {
        id: 'c2',
        name: 'Marcus Vance',
        company: 'Northwind Global Corp.',
        email: '[email protected]',
        address: '452 Broadway, 8th Floor, New York, NY 10013',
      },
      {
        id: 'c3',
        name: 'Elena Rostova',
        company: 'Hyperion Robotics',
        email: '[email protected]',
        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="[email protected]"
                          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="[email protected]"
                          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() || '[email protected]' }}
                    </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() || '[email protected]' }}</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 = '[email protected]'
      @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 = '[email protected]'
      @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 [email protected] 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('[email protected]')
      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('[email protected]')
      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 [email protected] 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

Raw manifest:https://uipkge.dev/r/angular/invoice-creator-wizard.json