Commute Reimbursement Claims
blockhrMonthly 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
$pnpm dlx uipkge-ng@latest add commute-reimbursement-claims$npx uipkge-ng@latest add commute-reimbursement-claims$yarn dlx uipkge-ng@latest add commute-reimbursement-claims$bunx uipkge-ng@latest add commute-reimbursement-claimsManifest:
https://uipkge.dev/r/angular/commute-reimbursement-claims.jsonInstalls to:src/app/components/blocks/commute-reimbursement-claims/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
ExpenseClaiminterface 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
export { UiCommuteReimbursementClaimsComponent, type CommuteCategory, type ClaimStatus, type ExpenseClaim, } from './commute-reimbursement-claims.component'
Raw manifest:https://uipkge.dev/r/angular/commute-reimbursement-claims.json