Menu

Change language

GitHub repo

Commute Reimbursement Claims

blockhr

Monthly commuter transit pass, mileage, and EV charging expense claim reimbursement portal with IRS pre-tax allowance metrics, claim submission form with receipt dropzone, and claims history ledger.

Installation

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

Variants

Loading interactive previews…

Schema

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

ExpenseClaim
interface ExpenseClaim {
  id: string
  date: string
  category: CommuteCategory
  categoryLabel: string
  description: string
  provider: string
  amount: number
  status: ClaimStatus
  statusLabel: string
  receiptFileName: string
  receiptFileSize: string
  receiptDate: string
  notes: string
}

Files installed (2)

  • src/app/components/blocks/commute-reimbursement-claims/commute-reimbursement-claims.component.ts48.8 kB
    // Monthly commuter transit pass, mileage, and EV charging expense claim reimbursement
    // portal with IRS pre-tax allowance metrics, claim submission form with receipt dropzone,
    // and claims history ledger. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      Building2,
      Calendar,
      Car,
      Check,
      CheckCircle2,
      Clock,
      DollarSign,
      Download,
      Eye,
      FileCheck,
      FileText,
      LucideAngularModule,
      Paperclip,
      Plus,
      Receipt,
      RotateCcw,
      Search,
      Send,
      ShieldCheck,
      Train,
      TrendingUp,
      Upload,
      Wallet,
      X,
      Zap,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import {
      UiDialogComponent,
      UiDialogContentComponent,
      UiDialogDescriptionComponent,
      UiDialogFooterComponent,
      UiDialogHeaderComponent,
      UiDialogTitleComponent,
    } from '@/ui/dialog/dialog.component'
    import {
      UiDropdownMenuComponent,
      UiDropdownMenuContentComponent,
      UiDropdownMenuItemComponent,
      UiDropdownMenuSeparatorComponent,
      UiDropdownMenuTriggerComponent,
    } from '@/ui/dropdown-menu/dropdown-menu.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiProgressComponent } from '@/ui/progress/progress.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    
    export type CommuteCategory = 'transit' | 'parking' | 'mileage' | 'ev_charging'
    export type ClaimStatus = 'reimbursed' | 'pending'
    
    export interface ExpenseClaim {
      id: string
      date: string
      category: CommuteCategory
      categoryLabel: string
      description: string
      provider: string
      amount: number
      status: ClaimStatus
      statusLabel: string
      receiptFileName: string
      receiptFileSize: string
      receiptDate: string
      notes: string
    }
    
    const categoryBadge: Record<CommuteCategory, string> = {
      transit: 'bg-info/10 text-info border-info/20',
      parking: 'bg-chart-1/10 text-chart-1 border-chart-1/20',
      mileage: 'bg-warning/10 text-warning border-warning/20',
      ev_charging: 'bg-chart-2/10 text-chart-2 border-chart-2/20',
    }
    
    const categoryIrsInfo: Record<CommuteCategory, string> = {
      transit: 'BART, Caltrain, Metro, Subway & Commuter Bus passes (IRC § 132(f))',
      parking: 'Park-and-ride commuter lots and near-office parking facilities',
      mileage: '2026 IRS standard rate: $0.67 per business/commuter detour mile',
      ev_charging: 'Level 2 & DC fast charging at public networks & workplace chargers',
    }
    
    const statusStyles: Record<ClaimStatus, string> = {
      reimbursed: 'bg-success/10 text-success border-success/20',
      pending: 'bg-warning/10 text-warning border-warning/20',
    }
    
    const statusDots: Record<ClaimStatus, string> = {
      reimbursed: 'bg-success',
      pending: 'bg-warning',
    }
    
    const defaultClaims: ExpenseClaim[] = [
      {
        id: 'CLM-8041',
        date: 'Aug 18, 2026',
        category: 'transit',
        categoryLabel: 'Public Transit',
        description: 'Monthly BART Clipper Card Pass',
        provider: 'Bay Area Rapid Transit (BART)',
        amount: 120.0,
        status: 'reimbursed',
        statusLabel: 'Reimbursed on Payroll',
        receiptFileName: 'bart-clipper-pass-aug2026.pdf',
        receiptFileSize: '340 KB',
        receiptDate: 'Aug 18, 2026 · 08:14 AM',
        notes: 'Monthly pass reload for Embarcadero ↔ Millbrae commuter route.',
      },
      {
        id: 'CLM-7982',
        date: 'Aug 12, 2026',
        category: 'transit',
        categoryLabel: 'Public Transit',
        description: 'San Francisco Caltrain Monthly Pass',
        provider: 'Caltrain Peninsula Corridor',
        amount: 60.5,
        status: 'reimbursed',
        statusLabel: 'Reimbursed on Payroll',
        receiptFileName: 'caltrain-zone1-3-pass.pdf',
        receiptFileSize: '412 KB',
        receiptDate: 'Aug 12, 2026 · 07:45 AM',
        notes: 'Zone 1 to Zone 3 Caltrain commuter pass for headquarters travel.',
      },
      {
        id: 'CLM-8104',
        date: 'Aug 08, 2026',
        category: 'parking',
        categoryLabel: 'Parking',
        description: 'Downtown Parking Garage',
        provider: 'SOMA Park Management Corp.',
        amount: 65.0,
        status: 'pending',
        statusLabel: 'Pending Approval',
        receiptFileName: 'soma-garage-monthly-receipt.pdf',
        receiptFileSize: '185 KB',
        receiptDate: 'Aug 08, 2026 · 09:30 AM',
        notes: 'Commuter parking space #204 during quarterly planning week.',
      },
      {
        id: 'CLM-7729',
        date: 'Aug 03, 2026',
        category: 'ev_charging',
        categoryLabel: 'EV Charging',
        description: 'EV Supercharger Network',
        provider: 'EVgo / ChargePoint Public Hub',
        amount: 42.0,
        status: 'reimbursed',
        statusLabel: 'Reimbursed on Payroll',
        receiptFileName: 'evgo-charge-sessions-aug.pdf',
        receiptFileSize: '520 KB',
        receiptDate: 'Aug 03, 2026 · 06:20 PM',
        notes: '4x workplace Level-2 charge sessions during sprint week.',
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-commute-reimbursement-claims, [ui-commute-reimbursement-claims]',
      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,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiDialogComponent,
        UiDialogContentComponent,
        UiDialogDescriptionComponent,
        UiDialogFooterComponent,
        UiDialogHeaderComponent,
        UiDialogTitleComponent,
        UiDropdownMenuComponent,
        UiDropdownMenuContentComponent,
        UiDropdownMenuItemComponent,
        UiDropdownMenuSeparatorComponent,
        UiDropdownMenuTriggerComponent,
        UiInputComponent,
        UiProgressComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiSeparatorComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <div data-slot="commute-reimbursement-claims" [class]="rootClass">
          <!-- Header -->
          <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div class="space-y-1">
              <div class="flex flex-wrap items-center gap-2.5">
                <h2 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">{{ title }}</h2>
                <span ui-badge wrap variant="outline" class="border-info/30 bg-info/10 text-info text-xs font-medium">
                  <lucide-icon [img]="Calendar" class="mr-1 size-3" aria-hidden="true" />
                  {{ claimPeriod }}
                </span>
              </div>
              <p class="text-muted-foreground text-sm">{{ policyText }}</p>
            </div>
    
            <div class="flex flex-wrap items-center gap-2.5">
              <button ui-button aria-label="Download attachment" variant="outline" size="sm" class="shadow-xs">
                <lucide-icon [img]="Download" class="size-4" aria-hidden="true" />
                Export Statement
              </button>
              <button ui-button size="sm" class="shadow-xs" (click)="scrollToSubmitForm()">
                <lucide-icon [img]="Plus" class="size-4" aria-hidden="true" />
                Submit New Claim
              </button>
            </div>
          </div>
    
          <!-- 4 Monthly Benefit Metric 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">Monthly Allowance</p>
                  <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg">
                    <lucide-icon [img]="Wallet" class="size-4" aria-hidden="true" />
                  </div>
                </div>
                <p class="text-foreground text-2xl font-bold tracking-tight tabular-nums">$315.00 / mo</p>
                <div class="text-muted-foreground flex items-center justify-between text-xs">
                  <span>IRS Pre-tax limit</span>
                  <span class="text-foreground font-medium tabular-nums">$69.50 left</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">Claimed This Month</p>
                  <div class="bg-info/10 text-info flex size-8 items-center justify-center rounded-lg">
                    <lucide-icon [img]="TrendingUp" class="size-4" aria-hidden="true" />
                  </div>
                </div>
                <p class="text-foreground text-2xl font-bold tracking-tight tabular-nums">$245.50</p>
                <div class="space-y-1.5 pt-0.5">
                  <ui-progress [value]="78" class="h-1.5" />
                  <p class="text-muted-foreground text-xs tabular-nums">$245.50 / $315.00 · 78% utilized</p>
                </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">Reimbursed YTD</p>
                  <div class="bg-success/10 text-success flex size-8 items-center justify-center rounded-lg">
                    <lucide-icon [img]="CheckCircle2" class="size-4" aria-hidden="true" />
                  </div>
                </div>
                <p class="text-foreground text-2xl font-bold tracking-tight tabular-nums">$1,890.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]="ShieldCheck" class="mr-1 size-3.5" aria-hidden="true" />
                    100% Settled
                  </span>
                  <span>· 6 claims in 2026</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 Review</p>
                  <div class="bg-warning/10 text-warning flex size-8 items-center justify-center rounded-lg">
                    <lucide-icon [img]="Clock" class="size-4" aria-hidden="true" />
                  </div>
                </div>
                <p class="text-foreground text-warning text-2xl font-bold tracking-tight tabular-nums">1 Claim · $65.00</p>
                <p class="text-muted-foreground text-xs">Est. payout on Sep 1st payroll</p>
              </div>
            </div>
          </div>
    
          <!-- Main 2-Column Portal Section -->
          <div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
            <!-- Left Column: Submit Claim Interactive Card & Policy Card -->
            <div class="space-y-6 lg:col-span-5">
              <div ui-card id="commute-submit-form" class="border-border shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="flex items-center justify-between">
                    <div>
                      <h3 ui-card-title class="text-base font-semibold">Submit Commute Claim</h3>
                      <p ui-card-description class="text-xs">
                        File transit pass, parking, mileage, or EV charging for monthly reimbursement.
                      </p>
                    </div>
                    <span ui-badge wrap variant="secondary" class="text-xs font-medium">{{ claimPeriod }}</span>
                  </div>
                </div>
    
                <div ui-card-content class="space-y-4">
                  @if (formSubmittedNotice()) {
                    <div
                      class="border-success/30 bg-success/10 text-success dark:text-foreground flex items-start gap-2.5 rounded-lg border p-3"
                    >
                      <lucide-icon [img]="CheckCircle2" class="text-success mt-0.5 size-4 shrink-0" />
                      <div class="text-xs">
                        <p class="font-semibold">Claim {{ recentlySubmittedClaimId() }} submitted successfully!</p>
                        <p class="text-success/90 mt-0.5">
                          Itemized receipt received. Your claim has been added to pending payroll review.
                        </p>
                      </div>
                    </div>
                  }
    
                  <div class="space-y-1.5">
                    <label for="commute-category-select" class="text-foreground text-xs font-medium">
                      Expense Category <span class="text-destructive">*</span>
                    </label>
                    <ui-select [value]="formCategory()" (valueChange)="handleCategoryChange($event)">
                      <button ui-select-trigger id="commute-category-select" class="w-full text-xs">
                        <ui-select-value placeholder="Select commute category" />
                      </button>
                      <ui-select-content>
                        <ui-select-item value="transit">
                          <span class="flex items-center gap-2">
                            <lucide-icon [img]="Train" class="text-info size-3.5" />
                            <span>Public Transit Pass: BART/Metro</span>
                          </span>
                        </ui-select-item>
                        <ui-select-item value="parking">
                          <span class="flex items-center gap-2">
                            <lucide-icon [img]="Building2" class="text-chart-1 size-3.5" />
                            <span>Parking (Station & Garage)</span>
                          </span>
                        </ui-select-item>
                        <ui-select-item value="mileage">
                          <span class="flex items-center gap-2">
                            <lucide-icon [img]="Car" class="text-warning size-3.5" />
                            <span>Mileage (IRS $0.67/mi)</span>
                          </span>
                        </ui-select-item>
                        <ui-select-item value="ev_charging">
                          <span class="flex items-center gap-2">
                            <lucide-icon [img]="Zap" class="text-success size-3.5" />
                            <span>EV Public Charging</span>
                          </span>
                        </ui-select-item>
                      </ui-select-content>
                    </ui-select>
                    <p class="text-muted-foreground text-xs">{{ categoryIrsInfo(formCategory()) }}</p>
                  </div>
    
                  <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
                    <div class="space-y-1.5">
                      <label for="commute-date-input" class="text-foreground text-xs font-medium">
                        Date of Commute <span class="text-destructive">*</span>
                      </label>
                      <ui-input
                        id="commute-date-input"
                        [value]="formDate()"
                        (valueChange)="formDate.set($event)"
                        type="date"
                        class="h-9 text-xs"
                      />
                    </div>
                    <div class="space-y-1.5">
                      <label for="commute-provider-input" class="text-foreground text-xs font-medium">
                        Provider / Authority <span class="text-destructive">*</span>
                      </label>
                      <ui-input
                        id="commute-provider-input"
                        [value]="formProvider()"
                        (valueChange)="formProvider.set($event)"
                        placeholder="e.g. BART, Caltrain, EVgo"
                        class="h-9 text-xs"
                      />
                    </div>
                  </div>
    
                  <div class="space-y-1.5">
                    <label for="commute-desc-input" class="text-foreground text-xs font-medium">
                      Claim Description & Route <span class="text-destructive">*</span>
                    </label>
                    <ui-input
                      id="commute-desc-input"
                      [value]="formDescription()"
                      (valueChange)="formDescription.set($event)"
                      placeholder="e.g. Monthly Clipper Pass · Embarcadero to Millbrae"
                      class="h-9 text-xs"
                    />
                  </div>
    
                  @if (formCategory() === 'mileage') {
                    <div class="border-border bg-muted/30 space-y-2 rounded-lg border p-3">
                      <div class="flex items-center justify-between text-xs">
                        <span class="text-muted-foreground font-medium">IRS Standard Mileage Rate</span>
                        <span ui-badge wrap variant="outline" class="text-xs font-normal">$0.67 / mile</span>
                      </div>
                      <div class="flex items-center gap-3">
                        <div class="flex-1">
                          <label for="commute-miles-input" class="text-muted-foreground text-xs">Total Miles Driven</label>
                          <ui-input
                            id="commute-miles-input"
                            [value]="formMiles()"
                            (valueChange)="handleMilesInput($event)"
                            type="number"
                            class="mt-1 h-8 text-xs tabular-nums"
                          />
                        </div>
                        <div class="flex-1 text-right">
                          <span class="text-muted-foreground text-xs">Calculated Total</span>
                          <p class="text-foreground text-base font-bold tabular-nums">\${{ formAmount() }}</p>
                        </div>
                      </div>
                    </div>
                  }
    
                  <div class="space-y-1.5">
                    <label for="commute-amount-input" class="text-foreground text-xs font-medium">
                      Claim Amount (USD) <span class="text-destructive">*</span>
                    </label>
                    <div class="relative">
                      <lucide-icon
                        [img]="DollarSign"
                        class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-3.5 -translate-y-1/2"
                      />
                      <ui-input
                        id="commute-amount-input"
                        [value]="formAmount()"
                        (valueChange)="formAmount.set($event)"
                        type="text"
                        placeholder="0.00"
                        class="h-9 pl-8 text-xs font-semibold tabular-nums"
                      />
                    </div>
                    <div class="text-muted-foreground flex items-center justify-between text-xs">
                      <span>Maximum pre-tax monthly cap: $315.00</span>
                      <span class="font-medium">Direct Payroll Payout</span>
                    </div>
                  </div>
    
                  <div class="space-y-1.5">
                    <label class="text-foreground text-xs font-medium">
                      Itemized Receipt Attachment <span class="text-destructive">*</span>
                    </label>
                    @if (formAttachedReceipt(); as receipt) {
                      <div
                        class="border-border bg-muted/40 flex items-center justify-between gap-3 rounded-lg border p-3 text-xs"
                      >
                        <div class="flex min-w-0 items-center gap-2.5">
                          <div
                            class="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-md"
                          >
                            <lucide-icon [img]="FileText" class="size-4" />
                          </div>
                          <div class="min-w-0">
                            <p class="text-foreground truncate font-medium">{{ receipt.name }}</p>
                            <p class="text-muted-foreground truncate">{{ receipt.size }} · Itemized proof attached</p>
                          </div>
                        </div>
                        <div class="flex items-center gap-1.5">
                          <span
                            ui-badge
                            wrap
                            variant="outline"
                            class="border-success/30 bg-success/10 text-success text-xs"
                          >
                            <lucide-icon [img]="Check" class="mr-1 size-3" />
                            Valid
                          </span>
                          <button
                            type="button"
                            ui-button
                            variant="ghost"
                            size="icon-sm"
                            class="text-muted-foreground hover:text-foreground size-7"
                            (click)="handleRemoveReceipt()"
                          >
                            <lucide-icon [img]="X" class="size-3.5" />
                            <span class="sr-only">Remove receipt</span>
                          </button>
                        </div>
                      </div>
                    } @else {
                      <button
                        type="button"
                        class="border-border hover:bg-muted/40 hover:border-primary/50 focus-visible:ring-ring flex w-full flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed p-4 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none"
                        (click)="handleAttachSampleReceipt()"
                      >
                        <div class="bg-muted text-muted-foreground flex size-8 items-center justify-center rounded-full">
                          <lucide-icon [img]="Upload" class="size-4" />
                        </div>
                        <p class="text-foreground text-xs font-medium">Click to upload or drag receipt file</p>
                        <p class="text-muted-foreground text-xs">PDF, PNG, or JPG up to 10MB (Itemized proof required)</p>
                      </button>
                    }
                  </div>
                </div>
    
                <div ui-card-footer class="border-border flex items-center justify-between border-t pt-4">
                  <button type="button" ui-button variant="ghost" size="sm" class="text-xs" (click)="handleResetForm()">
                    <lucide-icon [img]="RotateCcw" class="mr-1.5 size-3.5" />
                    Reset Form
                  </button>
                  <button
                    type="button"
                    ui-button
                    size="sm"
                    class="gap-1.5 text-xs shadow-xs"
                    [disabled]="!formAmount() || (parseAmount() ?? 0) <= 0"
                    (click)="handleSubmitClaim()"
                  >
                    <lucide-icon [img]="Send" class="size-3.5" />
                    Submit Claim
                  </button>
                </div>
              </div>
    
              <div ui-card class="border-border shadow-xs">
                <div ui-card-header class="pb-2">
                  <div class="flex items-center gap-2">
                    <lucide-icon [img]="ShieldCheck" class="text-primary size-4" />
                    <h3 ui-card-title class="text-sm font-semibold">Commuter Benefit Program Policy</h3>
                  </div>
                </div>
                <div ui-card-content class="space-y-2.5 text-xs">
                  <p class="text-muted-foreground leading-relaxed">
                    Expenses are reimbursed under Section 132(f) Qualified Transportation Fringe Benefits. Reimbursements
                    are disbursed directly into employee payroll on the 1st of every month.
                  </p>
                  <div class="border-border/60 text-muted-foreground space-y-1.5 border-t pt-2">
                    <div class="flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-success size-3" />
                      <span>Monthly maximum pre-tax election: $315.00</span>
                    </div>
                    <div class="flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-success size-3" />
                      <span>Receipts must reflect date, transit authority, and paid amount</span>
                    </div>
                    <div class="flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-success size-3" />
                      <span>Deadline for current month submissions: 25th of month</span>
                    </div>
                  </div>
                </div>
              </div>
            </div>
    
            <!-- Right Column: Expense Claims History Table -->
            <div class="space-y-4 lg:col-span-7">
              <div ui-card class="border-border shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                    <div>
                      <h3 ui-card-title class="text-base font-semibold">Expense Claims History</h3>
                      <p ui-card-description class="text-xs">
                        Itemized ledger of submitted commuter, transit, and EV expenses.
                      </p>
                    </div>
                    <span ui-badge wrap variant="outline" class="text-xs tabular-nums">
                      {{ claims().length }} Total Claims
                    </span>
                  </div>
                </div>
    
                <div ui-card-content class="space-y-4">
                  <div
                    class="border-border bg-muted/20 flex flex-col gap-2.5 rounded-lg border p-2.5 sm:flex-row sm:items-center sm:justify-between"
                  >
                    <div class="relative flex-1 sm:max-w-xs">
                      <lucide-icon
                        [img]="Search"
                        class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
                      />
                      <ui-input
                        [value]="searchQuery()"
                        (valueChange)="searchQuery.set($event)"
                        type="search"
                        placeholder="Search claims, ID, route..."
                        class="h-8 pl-8 text-xs"
                      />
                    </div>
    
                    <div class="flex flex-wrap items-center gap-2">
                      <ui-select [value]="categoryFilter()" (valueChange)="categoryFilter.set($event)">
                        <button ui-select-trigger class="h-8 w-36 text-xs">
                          <ui-select-value placeholder="All Categories" />
                        </button>
                        <ui-select-content>
                          <ui-select-item value="all">All Categories</ui-select-item>
                          <ui-select-item value="transit">Public Transit</ui-select-item>
                          <ui-select-item value="parking">Parking</ui-select-item>
                          <ui-select-item value="mileage">Mileage</ui-select-item>
                          <ui-select-item value="ev_charging">EV Charging</ui-select-item>
                        </ui-select-content>
                      </ui-select>
    
                      <ui-select [value]="statusFilter()" (valueChange)="statusFilter.set($event)">
                        <button ui-select-trigger class="h-8 w-36 text-xs">
                          <ui-select-value placeholder="All Statuses" />
                        </button>
                        <ui-select-content>
                          <ui-select-item value="all">All Statuses</ui-select-item>
                          <ui-select-item value="reimbursed">Reimbursed</ui-select-item>
                          <ui-select-item value="pending">Pending</ui-select-item>
                        </ui-select-content>
                      </ui-select>
    
                      @if (isFiltered()) {
                        <button ui-button variant="ghost" size="sm" class="h-8 px-2 text-xs" (click)="resetFilters()">
                          <lucide-icon [img]="RotateCcw" class="mr-1 size-3" />
                          Reset
                        </button>
                      }
                    </div>
                  </div>
    
                  <div class="border-border overflow-hidden rounded-lg border">
                    <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-[95px] text-xs">Claim ID</th>
                            <th ui-table-head class="min-w-[90px] text-xs">Date</th>
                            <th ui-table-head class="min-w-[110px] text-xs">Category</th>
                            <th ui-table-head class="min-w-[190px] text-xs">Description & Provider</th>
                            <th ui-table-head class="min-w-[90px] text-right text-xs">Amount</th>
                            <th ui-table-head class="min-w-[130px] text-xs">Status</th>
                            <th ui-table-head class="min-w-[80px] text-center text-xs">Receipt</th>
                            <th ui-table-head class="w-10 text-right text-xs">
                              <span class="sr-only">Actions</span>
                            </th>
                          </tr>
                        </thead>
                        <tbody ui-table-body>
                          @for (claim of filteredClaims(); track claim.id) {
                            <tr ui-table-row class="hover:bg-muted/50 transition-colors">
                              <td ui-table-cell class="font-mono text-xs font-medium">{{ claim.id }}</td>
                              <td ui-table-cell class="text-muted-foreground text-xs whitespace-nowrap tabular-nums">
                                {{ claim.date }}
                              </td>
                              <td ui-table-cell>
                                <span
                                  ui-badge
                                  wrap
                                  variant="outline"
                                  [class]="'text-xs font-normal ' + categoryBadgeClass(claim.category)"
                                >
                                  @switch (claim.category) {
                                    @case ('transit') {
                                      <lucide-icon [img]="Train" class="mr-1 size-3" />
                                    }
                                    @case ('parking') {
                                      <lucide-icon [img]="Building2" class="mr-1 size-3" />
                                    }
                                    @case ('mileage') {
                                      <lucide-icon [img]="Car" class="mr-1 size-3" />
                                    }
                                    @default {
                                      <lucide-icon [img]="Zap" class="mr-1 size-3" />
                                    }
                                  }
                                  {{ claim.categoryLabel }}
                                </span>
                              </td>
                              <td ui-table-cell>
                                <div class="min-w-0">
                                  <p class="text-foreground truncate text-xs font-medium">{{ claim.description }}</p>
                                  <p class="text-muted-foreground truncate text-xs">{{ claim.provider }}</p>
                                </div>
                              </td>
                              <td
                                ui-table-cell
                                class="text-foreground text-right text-xs font-semibold whitespace-nowrap tabular-nums"
                              >
                                \${{ claim.amount.toFixed(2) }}
                              </td>
                              <td ui-table-cell>
                                <span
                                  ui-badge
                                  wrap
                                  variant="outline"
                                  [class]="'text-xs font-medium ' + statusClass(claim.status)"
                                >
                                  <span
                                    [class]="statusDot(claim.status)"
                                    aria-hidden="true"
                                  ></span>
                                  {{ claim.statusLabel }}
                                </span>
                              </td>
                              <td ui-table-cell class="text-center">
                                <button
                                  ui-button
                                  aria-label="Attach file"
                                  variant="ghost"
                                  size="sm"
                                  class="h-7 px-2 text-xs"
                                  (click)="openReceipt(claim)"
                                >
                                  <lucide-icon [img]="Paperclip" class="text-muted-foreground mr-1 size-3" />
                                  <span>View</span>
                                </button>
                              </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 size-7"
                                  >
                                    <lucide-icon [img]="Eye" class="size-3.5" />
                                    <span class="sr-only">Actions for {{ claim.id }}</span>
                                  </button>
                                  <ui-dropdown-menu-content align="end" class="w-44">
                                    <ui-dropdown-menu-item class="cursor-pointer text-xs" (select)="openReceipt(claim)">
                                      <lucide-icon [img]="Receipt" class="mr-2 size-3.5" />
                                      <span>Inspect Receipt</span>
                                    </ui-dropdown-menu-item>
                                    <ui-dropdown-menu-item class="cursor-pointer text-xs">
                                      <lucide-icon [img]="FileCheck" class="mr-2 size-3.5" />
                                      <span>Policy Audit Log</span>
                                    </ui-dropdown-menu-item>
                                    <ui-dropdown-menu-separator />
                                    <ui-dropdown-menu-item class="cursor-pointer text-xs">
                                      <lucide-icon [img]="Download" class="mr-2 size-3.5" />
                                      <span>Download Voucher</span>
                                    </ui-dropdown-menu-item>
                                  </ui-dropdown-menu-content>
                                </ui-dropdown-menu>
                              </td>
                            </tr>
                          }
                          @if (filteredClaims().length === 0) {
                            <tr ui-table-row>
                              <td ui-table-cell [attr.colspan]="8" class="h-32 text-center">
                                <div class="flex flex-col items-center justify-center gap-1.5">
                                  <lucide-icon [img]="Search" class="text-muted-foreground/50 size-5" />
                                  <p class="text-foreground text-xs font-medium">No commute claims found</p>
                                  <p class="text-muted-foreground text-xs">Try resetting your category or status filter</p>
                                  <button
                                    ui-button
                                    variant="outline"
                                    size="sm"
                                    class="mt-1 h-7 text-xs"
                                    (click)="resetFilters()"
                                  >
                                    Reset filters
                                  </button>
                                </div>
                              </td>
                            </tr>
                          }
                        </tbody>
                      </table>
                    </div>
    
                    <div
                      class="border-border bg-muted/20 text-muted-foreground flex flex-col items-center justify-between gap-2 border-t px-3 py-2 text-xs sm:flex-row"
                    >
                      <p class="tabular-nums">
                        Showing <span class="text-foreground font-medium">{{ filteredClaims().length }}</span> of
                        <span class="text-foreground font-medium">{{ claims().length }}</span> claims
                      </p>
                      <div class="flex items-center gap-3">
                        <span class="tabular-nums">
                          Filtered Total:
                          <span class="text-foreground font-semibold">\${{ filteredTotal().toFixed(2) }}</span>
                        </span>
                      </div>
                    </div>
                  </div>
                </div>
              </div>
            </div>
          </div>
    
          <!-- Receipt Details Modal Dialog -->
          <ui-dialog [open]="isReceiptModalOpen()" (openChange)="isReceiptModalOpen.set($event)">
            <ui-dialog-content class="sm:max-w-md">
              <div ui-dialog-header>
                <div class="flex items-center justify-between pr-4">
                  <div class="space-y-0.5">
                    <h2 ui-dialog-title class="text-base font-bold">Commute Receipt Voucher</h2>
                    <p ui-dialog-description class="text-xs">IRS-compliant proof of transit and commuter benefit claim.</p>
                  </div>
                  <span ui-badge wrap variant="outline" class="font-mono text-xs">{{ selectedClaim().id }}</span>
                </div>
              </div>
    
              <div class="space-y-4 py-2 text-xs">
                <div class="border-border bg-muted/30 space-y-3 rounded-lg border p-3.5">
                  <div class="flex items-center justify-between border-b pb-2">
                    <div class="flex items-center gap-2">
                      @switch (selectedClaim().category) {
                        @case ('transit') {
                          <lucide-icon [img]="Train" class="text-primary size-4" />
                        }
                        @case ('parking') {
                          <lucide-icon [img]="Building2" class="text-primary size-4" />
                        }
                        @case ('mileage') {
                          <lucide-icon [img]="Car" class="text-primary size-4" />
                        }
                        @default {
                          <lucide-icon [img]="Zap" class="text-primary size-4" />
                        }
                      }
                      <span class="text-foreground font-semibold">{{ selectedClaim().provider }}</span>
                    </div>
                    <span ui-badge wrap [class]="'text-xs font-normal ' + statusClass(selectedClaim().status)">
                      {{ selectedClaim().statusLabel }}
                    </span>
                  </div>
    
                  <div class="grid grid-cols-1 gap-2 text-xs sm:grid-cols-2">
                    <div>
                      <span class="text-muted-foreground block">Transaction Date</span>
                      <span class="text-foreground font-medium">{{ selectedClaim().receiptDate }}</span>
                    </div>
                    <div>
                      <span class="text-muted-foreground block">Benefit Category</span>
                      <span class="text-foreground font-medium">{{ selectedClaim().categoryLabel }}</span>
                    </div>
                    <div class="sm:col-span-2">
                      <span class="text-muted-foreground block">Line Item Description</span>
                      <span class="text-foreground font-medium">{{ selectedClaim().description }}</span>
                    </div>
                    <div class="sm:col-span-2">
                      <span class="text-muted-foreground block">Employee Commute Note</span>
                      <span class="text-muted-foreground italic">"{{ selectedClaim().notes }}"</span>
                    </div>
                  </div>
    
                  <ui-separator />
    
                  <div class="space-y-1.5 text-xs">
                    <div class="text-muted-foreground flex justify-between">
                      <span>Base Fare / Charge</span>
                      <span class="font-mono tabular-nums">\${{ selectedClaim().amount.toFixed(2) }}</span>
                    </div>
                    <div class="text-muted-foreground flex justify-between">
                      <span>Pre-Tax IRS Exemption (IRC § 132f)</span>
                      <span class="text-success font-mono tabular-nums">-$0.00 (Tax Free)</span>
                    </div>
                    <div class="border-border flex justify-between border-t pt-1.5 text-sm font-bold">
                      <span class="text-foreground">Total Reimbursable Amount</span>
                      <span class="text-foreground font-mono tabular-nums">\${{ selectedClaim().amount.toFixed(2) }}</span>
                    </div>
                  </div>
    
                  <div class="border-border bg-background flex items-center justify-between rounded-md border p-2 text-xs">
                    <div class="flex items-center gap-2">
                      <lucide-icon [img]="FileText" class="text-primary size-3.5" />
                      <span class="font-mono text-xs">{{ selectedClaim().receiptFileName }}</span>
                      <span class="text-muted-foreground">({{ selectedClaim().receiptFileSize }})</span>
                    </div>
                    <span ui-badge wrap variant="outline" class="border-success/30 bg-success/10 text-success text-xs">
                      <lucide-icon [img]="ShieldCheck" class="mr-1 size-3" />
                      Verified
                    </span>
                  </div>
                </div>
              </div>
    
              <div ui-dialog-footer class="flex sm:justify-between">
                <button ui-button variant="outline" size="sm" class="text-xs" (click)="isReceiptModalOpen.set(false)">
                  Close
                </button>
                <button
                  ui-button
                  aria-label="Download attachment"
                  size="sm"
                  class="gap-1.5 text-xs"
                  (click)="isReceiptModalOpen.set(false)"
                >
                  <lucide-icon [img]="Download" class="size-3.5" />
                  Download Receipt PDF
                </button>
              </div>
            </ui-dialog-content>
          </ui-dialog>
        </div>
      `,
    })
    export class UiCommuteReimbursementClaimsComponent {
      protected readonly Building2 = Building2
      protected readonly Calendar = Calendar
      protected readonly Car = Car
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Clock = Clock
      protected readonly DollarSign = DollarSign
      protected readonly Download = Download
      protected readonly Eye = Eye
      protected readonly FileCheck = FileCheck
      protected readonly FileText = FileText
      protected readonly Paperclip = Paperclip
      protected readonly Plus = Plus
      protected readonly Receipt = Receipt
      protected readonly RotateCcw = RotateCcw
      protected readonly Search = Search
      protected readonly Send = Send
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Train = Train
      protected readonly TrendingUp = TrendingUp
      protected readonly Upload = Upload
      protected readonly Wallet = Wallet
      protected readonly X = X
      protected readonly Zap = Zap
    
      @Input() title = 'Commute & Transit Expense Claims'
      @Input() policyText = 'Monthly Commuter Benefit Program · Up to $315.00/mo IRS pre-tax limit'
      @Input() claimPeriod = 'August 2026'
      @Input() set initialClaims(v: ExpenseClaim[] | undefined) {
        if (v) {
          this.claims.set(v.map((c) => ({ ...c })))
          if (v.length > 0) this.selectedClaim.set({ ...v[0] })
        }
      }
      @Input('class') className?: string
    
      readonly claims = signal<ExpenseClaim[]>(defaultClaims.map((c) => ({ ...c })))
    
      readonly formCategory = signal<CommuteCategory>('transit')
      readonly formDate = signal('2026-08-20')
      readonly formProvider = signal('BART Clipper Card')
      readonly formDescription = signal('Monthly BART Clipper Card Pass')
      readonly formAmount = signal('120.00')
      readonly formMiles = signal('45')
      readonly formAttachedReceipt = signal<{ name: string; size: string } | null>({
        name: 'clipper-reload-receipt.pdf',
        size: '240 KB',
      })
      readonly formSubmittedNotice = signal(false)
      readonly recentlySubmittedClaimId = signal('')
    
      readonly searchQuery = signal('')
      readonly categoryFilter = signal('all')
      readonly statusFilter = signal('all')
    
      readonly isReceiptModalOpen = signal(false)
      readonly selectedClaim = signal<ExpenseClaim>({ ...defaultClaims[0] })
    
      readonly isFiltered = computed(() => {
        return this.searchQuery().trim() !== '' || this.categoryFilter() !== 'all' || this.statusFilter() !== 'all'
      })
    
      readonly filteredClaims = computed(() => {
        const q = this.searchQuery().trim().toLowerCase()
        return this.claims().filter((claim) => {
          const matchCat = this.categoryFilter() === 'all' || claim.category === this.categoryFilter()
          const matchStatus = this.statusFilter() === 'all' || claim.status === this.statusFilter()
          const matchQuery =
            !q ||
            claim.id.toLowerCase().includes(q) ||
            claim.description.toLowerCase().includes(q) ||
            claim.provider.toLowerCase().includes(q) ||
            claim.categoryLabel.toLowerCase().includes(q)
          return matchCat && matchStatus && matchQuery
        })
      })
    
      readonly filteredTotal = computed(() => this.filteredClaims().reduce((acc, c) => acc + c.amount, 0))
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      categoryBadgeClass(cat: CommuteCategory): string {
        return categoryBadge[cat]
      }
    
      categoryIrsInfo(cat: CommuteCategory): string {
        return categoryIrsInfo[cat]
      }
    
      statusClass(status: ClaimStatus): string {
        return statusStyles[status]
      }
    
      statusDot(status: ClaimStatus): string {
        return cn('mr-1.5 size-1.5 rounded-full', statusDots[status])
      }
    
      parseAmount(): number {
        return parseFloat(this.formAmount()) || 0
      }
    
      openReceipt(claim: ExpenseClaim): void {
        this.selectedClaim.set(claim)
        this.isReceiptModalOpen.set(true)
      }
    
      handleCategoryChange(val: string): void {
        const next = val as CommuteCategory
        this.formCategory.set(next)
        if (next === 'transit') {
          this.formProvider.set('BART Clipper Card')
          this.formDescription.set('Monthly BART Clipper Card Pass')
          this.formAmount.set('120.00')
        } else if (next === 'parking') {
          this.formProvider.set('Downtown Station Garage')
          this.formDescription.set('Monthly Station Parking Permit')
          this.formAmount.set('65.00')
        } else if (next === 'mileage') {
          this.formProvider.set('Personal Vehicle (IRS Standard Rate)')
          this.formDescription.set('Commute detour travel to client site')
          this.updateMileageAmount()
        } else if (next === 'ev_charging') {
          this.formProvider.set('EVgo Fast Charging')
          this.formDescription.set('Workplace EV public charging sessions')
          this.formAmount.set('42.00')
        }
      }
    
      handleMilesInput(val: string): void {
        this.formMiles.set(val)
        this.updateMileageAmount()
      }
    
      updateMileageAmount(): void {
        const miles = parseFloat(this.formMiles()) || 0
        this.formAmount.set((miles * 0.67).toFixed(2))
      }
    
      handleAttachSampleReceipt(): void {
        this.formAttachedReceipt.set({
          name: `receipt-${this.formCategory()}-${Date.now().toString().slice(-4)}.pdf`,
          size: '318 KB',
        })
      }
    
      handleRemoveReceipt(): void {
        this.formAttachedReceipt.set(null)
      }
    
      handleSubmitClaim(): void {
        const parsedAmt = parseFloat(this.formAmount()) || 0
        if (parsedAmt <= 0) return
        const meta: Record<CommuteCategory, string> = {
          transit: 'Public Transit',
          parking: 'Parking',
          mileage: 'Mileage',
          ev_charging: 'EV Charging',
        }
        const fallbackLabel: Record<CommuteCategory, string> = {
          transit: 'Public Transit Pass: BART/Metro',
          parking: 'Parking (Station & Garage)',
          mileage: 'Mileage (IRS $0.67/mi)',
          ev_charging: 'EV Public Charging',
        }
        const cat = this.formCategory()
        const newId = `CLM-${Math.floor(8200 + Math.random() * 700)}`
        const newClaim: ExpenseClaim = {
          id: newId,
          date: 'Aug 21, 2026',
          category: cat,
          categoryLabel: meta[cat],
          description: this.formDescription() || fallbackLabel[cat],
          provider: this.formProvider() || 'Transit Provider',
          amount: parsedAmt,
          status: 'pending',
          statusLabel: 'Pending Approval',
          receiptFileName: this.formAttachedReceipt()?.name || 'commute-claim-receipt.pdf',
          receiptFileSize: this.formAttachedReceipt()?.size || '290 KB',
          receiptDate: 'Aug 21, 2026 · Just now',
          notes: `Submitted via Commuter Benefit Portal for ${this.claimPeriod}.`,
        }
        this.claims.update((prev) => [newClaim, ...prev])
        this.recentlySubmittedClaimId.set(newId)
        this.formSubmittedNotice.set(true)
        setTimeout(() => this.formSubmittedNotice.set(false), 6000)
      }
    
      handleResetForm(): void {
        this.formCategory.set('transit')
        this.formDate.set('2026-08-20')
        this.formProvider.set('BART Clipper Card')
        this.formDescription.set('Monthly BART Clipper Card Pass')
        this.formAmount.set('120.00')
        this.formMiles.set('45')
        this.formAttachedReceipt.set({ name: 'clipper-reload-receipt.pdf', size: '240 KB' })
        this.formSubmittedNotice.set(false)
      }
    
      resetFilters(): void {
        this.searchQuery.set('')
        this.categoryFilter.set('all')
        this.statusFilter.set('all')
      }
    
      scrollToSubmitForm(): void {
        const el = document.getElementById('commute-submit-form')
        if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
      }
    }
    
  • src/app/components/blocks/commute-reimbursement-claims/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/commute-reimbursement-claims.json