Menu

Change language

GitHub repo

Transaction Feed

blockfinance

Brex/Ramp-style real-time corporate transaction feed with expense categorization, summary financial stat cards, search and filter toolbar, cardholder metadata, receipt attachment triggers, and row action menus.

Installation

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

Variants

Loading interactive previews…

Schema

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

Cardholder
interface Cardholder {
  name: string
  avatar: string
  initials: string
  cardLast4: string
  cardType: 'Virtual' | 'Physical'
}
Transaction
interface Transaction {
  id: string
  merchant: string
  merchantNote: string
  merchantIcon: LucideIconData
  category: string
  categoryKey: CategoryKey
  cardholder: Cardholder
  date: string
  status: TransactionStatus
  statusLabel: string
  amount: string
  isRefund: boolean
  hasReceipt: boolean
  receiptFileName?: string
}

Files installed (2)

  • src/app/components/blocks/transaction-feed/transaction-feed.component.ts27.6 kB
    // Brex/Ramp-style real-time corporate transaction feed with expense categorization,
    // summary financial stat cards, search and filter toolbar, cardholder metadata, receipt
    // attachment triggers, and row action menus. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      Building2,
      Car,
      Clock,
      Cloud,
      CreditCard,
      Download,
      Eye,
      FileCheck,
      GitBranch,
      LucideAngularModule,
      Mail,
      MessageSquare,
      MoreHorizontal,
      Palette,
      Paperclip,
      Plane,
      Plus,
      Receipt,
      RotateCcw,
      Search,
      ShieldAlert,
      TrendingUp,
      Upload,
      Utensils,
      type LucideIconData,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent } from '@/ui/avatar/avatar.component'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiCardComponent, UiCardContentComponent } from '@/ui/card/card.component'
    import {
      UiDropdownMenuComponent,
      UiDropdownMenuContentComponent,
      UiDropdownMenuItemComponent,
      UiDropdownMenuSeparatorComponent,
      UiDropdownMenuTriggerComponent,
    } from '@/ui/dropdown-menu/dropdown-menu.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    
    export type TransactionStatus = 'cleared' | 'pending' | 'receipt_needed'
    export type CategoryKey = 'cloud' | 'travel' | 'saas' | 'transport' | 'office' | 'meals'
    
    export interface Cardholder {
      name: string
      avatar: string
      initials: string
      cardLast4: string
      cardType: 'Virtual' | 'Physical'
    }
    
    export interface Transaction {
      id: string
      merchant: string
      merchantNote: string
      merchantIcon: LucideIconData
      category: string
      categoryKey: CategoryKey
      cardholder: Cardholder
      date: string
      status: TransactionStatus
      statusLabel: string
      amount: string
      isRefund: boolean
      hasReceipt: boolean
      receiptFileName?: string
    }
    
    const categoryStyles: Record<CategoryKey, string> = {
      cloud: 'bg-info/10 text-info border-info/20',
      travel: 'bg-chart-1/10 text-chart-1 border-chart-1/20',
      saas: 'bg-chart-2/10 text-chart-2 border-chart-2/20',
      transport: 'bg-warning/10 text-warning border-warning/20',
      office: 'bg-chart-3/10 text-chart-3 border-chart-3/20',
      meals: 'bg-success/10 text-success border-success/20',
    }
    
    const statusStyles: Record<TransactionStatus, string> = {
      cleared: 'bg-success/10 text-success border-success/20',
      pending: 'bg-info/10 text-info border-info/20',
      receipt_needed: 'bg-warning/10 text-warning border-warning/20',
    }
    
    const statusDots: Record<TransactionStatus, string> = {
      cleared: 'bg-success',
      pending: 'bg-info',
      receipt_needed: 'bg-warning',
    }
    
    const transactions: Transaction[] = [
      {
        id: 'tx-01',
        merchant: 'AWS Cloud Services',
        merchantNote: 'Production us-east-1 compute & S3',
        merchantIcon: Cloud,
        category: 'Cloud Hosting',
        categoryKey: 'cloud',
        cardholder: {
          name: 'Elena Rostova',
          avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&auto=format&fit=crop&q=80',
          initials: 'ER',
          cardLast4: '4892',
          cardType: 'Virtual',
        },
        date: 'Aug 21, 2026 · 14:28',
        status: 'cleared',
        statusLabel: 'Cleared',
        amount: '-$1,420.00',
        isRefund: false,
        hasReceipt: true,
        receiptFileName: 'aws-invoice-aug-2026.pdf',
      },
      {
        id: 'tx-02',
        merchant: 'Delta Air Lines',
        merchantNote: 'SFO → JFK nonstop (Q3 Conf)',
        merchantIcon: Plane,
        category: 'Travel',
        categoryKey: 'travel',
        cardholder: {
          name: 'Marcus Chen',
          avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&auto=format&fit=crop&q=80',
          initials: 'MC',
          cardLast4: '3190',
          cardType: 'Physical',
        },
        date: 'Aug 21, 2026 · 11:15',
        status: 'pending',
        statusLabel: 'Pending',
        amount: '-$849.00',
        isRefund: false,
        hasReceipt: true,
        receiptFileName: 'delta-eticket-dl8932.pdf',
      },
      {
        id: 'tx-03',
        merchant: 'GitHub Enterprise',
        merchantNote: '42 Enterprise team seats',
        merchantIcon: GitBranch,
        category: 'SaaS',
        categoryKey: 'saas',
        cardholder: {
          name: 'Amara Okafor',
          avatar: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=100&auto=format&fit=crop&q=80',
          initials: 'AO',
          cardLast4: '9455',
          cardType: 'Virtual',
        },
        date: 'Aug 20, 2026 · 09:00',
        status: 'cleared',
        statusLabel: 'Cleared',
        amount: '-$882.00',
        isRefund: false,
        hasReceipt: true,
        receiptFileName: 'github-inv-aug2026.pdf',
      },
      {
        id: 'tx-04',
        merchant: 'Uber for Business',
        merchantNote: 'Client dinner return ride',
        merchantIcon: Car,
        category: 'Transport',
        categoryKey: 'transport',
        cardholder: {
          name: 'Sarah Jenkins',
          avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&auto=format&fit=crop&q=80',
          initials: 'SJ',
          cardLast4: '6021',
          cardType: 'Physical',
        },
        date: 'Aug 19, 2026 · 22:45',
        status: 'receipt_needed',
        statusLabel: 'Receipt Needed',
        amount: '-$48.50',
        isRefund: false,
        hasReceipt: false,
      },
      {
        id: 'tx-05',
        merchant: 'Figma Professional',
        merchantNote: 'Design team organization plan',
        merchantIcon: Palette,
        category: 'SaaS',
        categoryKey: 'saas',
        cardholder: {
          name: 'Priya Nair',
          avatar: 'https://images.unsplash.com/photo-1580489944761-15a19d654956?w=100&auto=format&fit=crop&q=80',
          initials: 'PN',
          cardLast4: '7712',
          cardType: 'Virtual',
        },
        date: 'Aug 19, 2026 · 16:30',
        status: 'cleared',
        statusLabel: 'Cleared',
        amount: '-$360.00',
        isRefund: false,
        hasReceipt: true,
        receiptFileName: 'figma-subscription-receipt.pdf',
      },
      {
        id: 'tx-06',
        merchant: 'WeWork Coworking',
        merchantNote: 'NYC satellite team hot desks',
        merchantIcon: Building2,
        category: 'Office',
        categoryKey: 'office',
        cardholder: {
          name: 'Jonas Weber',
          avatar: 'https://images.unsplash.com/photo-1522075469751-3a6694fb2f61?w=100&auto=format&fit=crop&q=80',
          initials: 'JW',
          cardLast4: '1109',
          cardType: 'Physical',
        },
        date: 'Aug 18, 2026 · 10:00',
        status: 'receipt_needed',
        statusLabel: 'Receipt Needed',
        amount: '-$1,250.00',
        isRefund: false,
        hasReceipt: false,
      },
      {
        id: 'tx-07',
        merchant: 'Google Workspace',
        merchantNote: 'Security & suite - 55 users',
        merchantIcon: Mail,
        category: 'SaaS',
        categoryKey: 'saas',
        cardholder: {
          name: 'Elena Rostova',
          avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&auto=format&fit=crop&q=80',
          initials: 'ER',
          cardLast4: '4892',
          cardType: 'Virtual',
        },
        date: 'Aug 17, 2026 · 08:30',
        status: 'cleared',
        statusLabel: 'Cleared',
        amount: '-$660.00',
        isRefund: false,
        hasReceipt: true,
        receiptFileName: 'google-workspace-statement.pdf',
      },
      {
        id: 'tx-08',
        merchant: 'Slack Pro',
        merchantNote: 'Vendor annual prepay refund adjustment',
        merchantIcon: MessageSquare,
        category: 'SaaS',
        categoryKey: 'saas',
        cardholder: {
          name: 'Amara Okafor',
          avatar: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=100&auto=format&fit=crop&q=80',
          initials: 'AO',
          cardLast4: '9455',
          cardType: 'Virtual',
        },
        date: 'Aug 16, 2026 · 15:40',
        status: 'cleared',
        statusLabel: 'Cleared',
        amount: '+$5,000.00',
        isRefund: true,
        hasReceipt: true,
        receiptFileName: 'slack-credit-adjustment.pdf',
      },
      {
        id: 'tx-09',
        merchant: 'Sweetgreen Catering',
        merchantNote: 'Quarterly all-hands team lunch',
        merchantIcon: Utensils,
        category: 'Meals',
        categoryKey: 'meals',
        cardholder: {
          name: 'Marcus Chen',
          avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&auto=format&fit=crop&q=80',
          initials: 'MC',
          cardLast4: '3190',
          cardType: 'Physical',
        },
        date: 'Aug 15, 2026 · 12:15',
        status: 'receipt_needed',
        statusLabel: 'Receipt Needed',
        amount: '-$320.00',
        isRefund: false,
        hasReceipt: false,
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-transaction-feed, [ui-transaction-feed]',
      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,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiAvatarImageComponent,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiDropdownMenuComponent,
        UiDropdownMenuContentComponent,
        UiDropdownMenuItemComponent,
        UiDropdownMenuSeparatorComponent,
        UiDropdownMenuTriggerComponent,
        UiInputComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiTableComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <div data-slot="transaction-feed" [class]="rootClass">
          <!-- Header -->
          <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div class="space-y-1">
              <h2 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">Transactions</h2>
              <p class="text-muted-foreground text-sm">
                Real-time ledger of card expenses, wire transfers, and subscription charges.
              </p>
            </div>
            <div class="flex items-center gap-2.5">
              <button ui-button aria-label="Download attachment" variant="outline" size="sm">
                <lucide-icon [img]="Download" class="size-4" />
                Export CSV
              </button>
              <button ui-button size="sm">
                <lucide-icon [img]="Plus" class="size-4" />
                Add Transaction
              </button>
            </div>
          </div>
    
          <!-- 4 Financial Stat Cards -->
          <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            <div ui-card class="border-border shadow-xs">
              <div ui-card-content class="space-y-2 p-5">
                <div class="flex items-center justify-between">
                  <p class="text-muted-foreground text-sm font-medium">Total Spent This Month</p>
                  <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg">
                    <lucide-icon [img]="CreditCard" class="size-4" />
                  </div>
                </div>
                <p class="text-foreground text-2xl font-bold tracking-tight tabular-nums">$42,850.00</p>
                <div class="text-muted-foreground flex items-center gap-1.5 text-xs">
                  <span class="text-success inline-flex items-center font-medium">
                    <lucide-icon [img]="TrendingUp" class="mr-0.5 size-3.5" />
                    +8.4%
                  </span>
                  <span>vs previous month</span>
                </div>
              </div>
            </div>
    
            <div ui-card class="border-border shadow-xs">
              <div ui-card-content class="space-y-2 p-5">
                <div class="flex items-center justify-between">
                  <p class="text-muted-foreground text-sm font-medium">Pending Approvals</p>
                  <div class="bg-info/10 text-info flex size-8 items-center justify-center rounded-lg">
                    <lucide-icon [img]="Clock" class="size-4" />
                  </div>
                </div>
                <p class="text-foreground text-2xl font-bold tracking-tight tabular-nums">6 transactions</p>
                <p class="text-muted-foreground text-xs">$3,410.00 requiring review</p>
              </div>
            </div>
    
            <div ui-card class="border-border shadow-xs">
              <div ui-card-content class="space-y-2 p-5">
                <div class="flex items-center justify-between">
                  <p class="text-muted-foreground text-sm font-medium">Missing Receipts</p>
                  <span ui-badge wrap variant="outline" class="border-warning/30 bg-warning/10 text-warning text-xs">
                    Action required
                  </span>
                </div>
                <p class="text-foreground text-2xl font-bold tracking-tight tabular-nums">3</p>
                <p class="text-muted-foreground text-xs">Total $1,618.50 unverified</p>
              </div>
            </div>
    
            <div ui-card class="border-border shadow-xs">
              <div ui-card-content class="space-y-2 p-5">
                <div class="flex items-center justify-between">
                  <p class="text-muted-foreground text-sm font-medium">Average Transaction</p>
                  <div class="bg-muted text-muted-foreground flex size-8 items-center justify-center rounded-lg">
                    <lucide-icon [img]="Receipt" class="size-4" />
                  </div>
                </div>
                <p class="text-foreground text-2xl font-bold tracking-tight tabular-nums">$185.20</p>
                <p class="text-muted-foreground text-xs">Across 231 card charges</p>
              </div>
            </div>
          </div>
    
          <!-- Filter Toolbar -->
          <div
            class="border-border bg-card flex flex-col gap-3 rounded-lg border p-3 shadow-xs md:flex-row md:items-center md:justify-between"
          >
            <div class="flex flex-1 flex-col gap-2.5 sm:flex-row sm:items-center">
              <div class="relative min-w-0 flex-1">
                <lucide-icon
                  [img]="Search"
                  class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
                />
                <ui-input
                  [value]="search()"
                  (valueChange)="search.set($event)"
                  type="search"
                  placeholder="Search transactions..."
                  class="h-9 w-full pl-9 text-xs sm:text-sm"
                />
              </div>
    
              <div class="flex flex-wrap items-center gap-2">
                <ui-select [value]="categoryFilter()" (valueChange)="categoryFilter.set($event)">
                  <button ui-select-trigger class="h-9 w-full text-xs sm:w-40">
                    <ui-select-value placeholder="All Categories" />
                  </button>
                  <ui-select-content>
                    <ui-select-item value="all">All Categories</ui-select-item>
                    <ui-select-item value="cloud">Cloud Hosting</ui-select-item>
                    <ui-select-item value="saas">Software & SaaS</ui-select-item>
                    <ui-select-item value="travel">Travel</ui-select-item>
                    <ui-select-item value="meals">Meals & Dining</ui-select-item>
                    <ui-select-item value="office">Office</ui-select-item>
                    <ui-select-item value="transport">Transport</ui-select-item>
                  </ui-select-content>
                </ui-select>
    
                <ui-select [value]="dateRangeFilter()" (valueChange)="dateRangeFilter.set($event)">
                  <button ui-select-trigger class="h-9 w-full text-xs sm:w-36">
                    <ui-select-value placeholder="Date Range" />
                  </button>
                  <ui-select-content>
                    <ui-select-item value="this_month">This Month</ui-select-item>
                    <ui-select-item value="last_30">Last 30 Days</ui-select-item>
                    <ui-select-item value="last_90">Last 90 Days</ui-select-item>
                    <ui-select-item value="ytd">Year to Date</ui-select-item>
                    <ui-select-item value="all_time">All Time</ui-select-item>
                  </ui-select-content>
                </ui-select>
    
                <ui-select [value]="statusFilter()" (valueChange)="statusFilter.set($event)">
                  <button ui-select-trigger class="h-9 w-full text-xs sm:w-36">
                    <ui-select-value placeholder="All Statuses" />
                  </button>
                  <ui-select-content>
                    <ui-select-item value="all">All Statuses</ui-select-item>
                    <ui-select-item value="cleared">Cleared</ui-select-item>
                    <ui-select-item value="pending">Pending</ui-select-item>
                    <ui-select-item value="receipt_needed">Receipt Needed</ui-select-item>
                  </ui-select-content>
                </ui-select>
              </div>
            </div>
    
            @if (isFiltered()) {
              <button
                ui-button
                variant="ghost"
                size="sm"
                class="text-muted-foreground hover:text-foreground h-9 self-start px-2 text-xs md:self-auto"
                (click)="resetFilters()"
              >
                <lucide-icon [img]="RotateCcw" class="mr-1.5 size-3.5" />
                Reset filters
              </button>
            }
          </div>
    
          <!-- Transactions Table -->
          <div class="border-border bg-card overflow-hidden rounded-lg border shadow-xs">
            <div class="overflow-x-auto">
              <table ui-table>
                <thead ui-table-header class="bg-muted/40">
                  <tr ui-table-row>
                    <th ui-table-head class="min-w-[220px]">Merchant / Note</th>
                    <th ui-table-head class="min-w-[130px]">Category</th>
                    <th ui-table-head class="min-w-[180px]">Cardholder</th>
                    <th ui-table-head class="min-w-[150px]">Date & Time</th>
                    <th ui-table-head class="min-w-[130px]">Status</th>
                    <th ui-table-head class="min-w-[120px] text-right">Amount</th>
                    <th ui-table-head class="min-w-[110px] text-center">Receipt</th>
                    <th ui-table-head class="w-12 text-right">
                      <span class="sr-only">Actions</span>
                    </th>
                  </tr>
                </thead>
                <tbody ui-table-body>
                  @for (tx of filteredTransactions(); track tx.id) {
                    <tr ui-table-row class="hover:bg-muted/50 transition-colors">
                      <td ui-table-cell>
                        <div class="flex items-center gap-3">
                          <div
                            class="border-border/60 bg-muted/60 text-foreground flex size-9 shrink-0 items-center justify-center rounded-lg border shadow-xs"
                          >
                            <lucide-icon [img]="tx.merchantIcon" class="size-4" />
                          </div>
                          <div class="min-w-0">
                            <p class="text-foreground truncate text-sm font-medium">{{ tx.merchant }}</p>
                            <p class="text-muted-foreground truncate text-xs">{{ tx.merchantNote }}</p>
                          </div>
                        </div>
                      </td>
    
                      <td ui-table-cell>
                        <span
                          ui-badge
                          variant="outline"
                          [class]="cn('text-xs font-normal', categoryStyles[tx.categoryKey])"
                        >
                          {{ tx.category }}
                        </span>
                      </td>
    
                      <td ui-table-cell>
                        <div class="flex items-center gap-2.5">
                          <ui-avatar class="size-7">
                            <img ui-avatar-image [src]="tx.cardholder.avatar" [alt]="tx.cardholder.name" />
                            <span ui-avatar-fallback class="text-xs">{{ tx.cardholder.initials }}</span>
                          </ui-avatar>
                          <div class="min-w-0">
                            <p class="text-foreground truncate text-xs font-medium">{{ tx.cardholder.name }}</p>
                            <p class="text-muted-foreground flex items-center gap-1 font-mono text-xs">
                              <span>••••</span>
                              <span>{{ tx.cardholder.cardLast4 }}</span>
                            </p>
                          </div>
                        </div>
                      </td>
    
                      <td ui-table-cell class="text-muted-foreground text-xs whitespace-nowrap tabular-nums">
                        {{ tx.date }}
                      </td>
    
                      <td ui-table-cell>
                        <span
                          ui-badge
                          variant="outline"
                          [class]="cn('text-xs font-medium capitalize', statusStyles[tx.status])"
                        >
                          <span [class]="cn('mr-1.5 size-1.5 rounded-full', statusDots[tx.status])"></span>
                          {{ tx.statusLabel }}
                        </span>
                      </td>
    
                      <td ui-table-cell class="text-right text-sm font-semibold whitespace-nowrap tabular-nums">
                        <span [class]="tx.isRefund ? 'text-success' : 'text-foreground'">{{ tx.amount }}</span>
                      </td>
    
                      <td ui-table-cell class="text-center">
                        <div class="flex items-center justify-center">
                          @if (tx.hasReceipt) {
                            <span
                              class="text-muted-foreground hover:text-foreground bg-muted/60 hover:bg-muted inline-flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors"
                              [attr.title]="tx.receiptFileName || 'View receipt'"
                            >
                              <lucide-icon [img]="Paperclip" class="text-muted-foreground size-3.5" />
                              <span>Attached</span>
                            </span>
                          } @else {
                            <button
                              type="button"
                              class="focus-visible:ring-ring bg-warning/10 text-warning hover:bg-warning/20 inline-flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
                            >
                              <lucide-icon [img]="Upload" class="size-3" />
                              <span>Upload</span>
                            </button>
                          }
                        </div>
                      </td>
    
                      <td ui-table-cell class="text-right">
                        <ui-dropdown-menu>
                          <button
                            ui-button
                            ui-dropdown-menu-trigger
                            variant="ghost"
                            size="icon-sm"
                            class="text-muted-foreground hover:text-foreground"
                          >
                            <lucide-icon [img]="MoreHorizontal" class="size-4" />
                            <span class="sr-only">Open actions for {{ tx.merchant }}</span>
                          </button>
                          <ui-dropdown-menu-content align="end" class="w-44">
                            <ui-dropdown-menu-item>
                              <lucide-icon [img]="Eye" class="mr-2 size-4" />
                              View Details
                            </ui-dropdown-menu-item>
                            <ui-dropdown-menu-item>
                              <lucide-icon [img]="Paperclip" class="mr-2 size-4" />
                              {{ tx.hasReceipt ? 'View Receipt' : 'Attach Receipt' }}
                            </ui-dropdown-menu-item>
                            <ui-dropdown-menu-item>
                              <lucide-icon [img]="FileCheck" class="mr-2 size-4" />
                              Match Invoice
                            </ui-dropdown-menu-item>
                            <ui-dropdown-menu-separator />
                            <ui-dropdown-menu-item class="text-destructive focus:text-destructive focus:bg-destructive/10">
                              <lucide-icon [img]="ShieldAlert" class="mr-2 size-4" />
                              Dispute
                            </ui-dropdown-menu-item>
                          </ui-dropdown-menu-content>
                        </ui-dropdown-menu>
                      </td>
                    </tr>
                  }
    
                  @if (filteredTransactions().length === 0) {
                    <tr ui-table-row>
                      <td ui-table-cell [attr.colspan]="8" class="h-36 text-center">
                        <div class="flex flex-col items-center justify-center gap-2">
                          <lucide-icon [img]="Search" class="text-muted-foreground/50 size-6" />
                          <p class="text-foreground text-sm font-medium">No transactions match your filters</p>
                          <p class="text-muted-foreground text-xs">
                            Try clearing your search query or reset category/status
                          </p>
                          <button ui-button variant="outline" size="sm" class="mt-2 text-xs" (click)="resetFilters()">
                            Reset filters
                          </button>
                        </div>
                      </td>
                    </tr>
                  }
                </tbody>
              </table>
            </div>
    
            <!-- Pagination footer strip -->
            <div
              class="border-border bg-card text-muted-foreground flex flex-col items-center justify-between gap-3 border-t px-4 py-3 text-xs sm:flex-row"
            >
              <p class="tabular-nums">
                Showing <span class="text-foreground font-medium">{{ filteredTransactions().length }}</span> of
                <span class="text-foreground font-medium">{{ allTransactions.length }}</span> transactions
              </p>
              <div class="flex items-center gap-2">
                <button ui-button variant="outline" size="sm" class="h-8 text-xs" disabled>Previous</button>
                <button ui-button variant="outline" size="sm" class="h-8 text-xs" disabled>Next</button>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiTransactionFeedComponent {
      protected readonly Clock = Clock
      protected readonly CreditCard = CreditCard
      protected readonly Download = Download
      protected readonly Eye = Eye
      protected readonly FileCheck = FileCheck
      protected readonly MoreHorizontal = MoreHorizontal
      protected readonly Paperclip = Paperclip
      protected readonly Plus = Plus
      protected readonly Receipt = Receipt
      protected readonly RotateCcw = RotateCcw
      protected readonly Search = Search
      protected readonly ShieldAlert = ShieldAlert
      protected readonly TrendingUp = TrendingUp
      protected readonly Upload = Upload
    
      protected readonly cn = cn
      protected readonly categoryStyles = categoryStyles
      protected readonly statusStyles = statusStyles
      protected readonly statusDots = statusDots
      protected readonly allTransactions = transactions
    
      @Input('class') className?: string
    
      readonly search = signal('')
      readonly categoryFilter = signal('all')
      readonly dateRangeFilter = signal('this_month')
      readonly statusFilter = signal('all')
    
      readonly isFiltered = computed(
        () =>
          this.search().trim() !== '' ||
          this.categoryFilter() !== 'all' ||
          this.dateRangeFilter() !== 'this_month' ||
          this.statusFilter() !== 'all',
      )
    
      readonly filteredTransactions = computed(() => {
        const query = this.search().trim().toLowerCase()
        return transactions.filter((tx) => {
          const matchesCategory = this.categoryFilter() === 'all' || tx.categoryKey === this.categoryFilter()
          const matchesStatus = this.statusFilter() === 'all' || tx.status === this.statusFilter()
          const matchesQuery =
            !query ||
            tx.merchant.toLowerCase().includes(query) ||
            tx.merchantNote.toLowerCase().includes(query) ||
            tx.category.toLowerCase().includes(query) ||
            tx.cardholder.name.toLowerCase().includes(query) ||
            tx.cardholder.cardLast4.includes(query)
          return matchesCategory && matchesStatus && matchesQuery
        })
      })
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      resetFilters(): void {
        this.search.set('')
        this.categoryFilter.set('all')
        this.dateRangeFilter.set('this_month')
        this.statusFilter.set('all')
      }
    }
    
  • src/app/components/blocks/transaction-feed/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/transaction-feed.json