A Ramp-style expense row with receipt thumbnail, category, amount, and status — with a policy-flag strip that expands beneath when a spend rule trips.
npx shadcn@latest add @paragon/expense-card"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
AlertTriangle,
Car,
Check,
Coffee,
Plane,
Receipt,
UtensilsCrossed,
} from "lucide-react";
import { cn } from "@/lib/utils";
type Category = "meals" | "travel" | "transport" | "software" | "other";
const CATEGORY_META: Record<
Category,
{ label: string; icon: React.ComponentType<{ className?: string }> }
> = {
meals: { label: "Meals", icon: UtensilsCrossed },
travel: { label: "Travel", icon: Plane },
transport: { label: "Transport", icon: Car },
software: { label: "Software", icon: Coffee },
other: { label: "Other", icon: Receipt },
};
export type ExpenseStatus = "approved" | "pending" | "flagged";
export interface ExpenseCardProps extends React.ComponentProps<"div"> {
merchant: string;
amount: number;
currency?: string;
date: string;
category: Category;
status?: ExpenseStatus;
/** Reason shown when a policy rule is tripped. */
policyFlag?: string;
/** True when a receipt image is attached. */
hasReceipt?: boolean;
cardLast4?: string;
}
/**
* A Ramp-style expense row: receipt thumbnail on the left (a placeholder
* ledger glyph when none is attached), merchant + category in the middle,
* amount and status on the right. When a policy rule trips, a flag strip
* expands beneath with the reason — grid-rows 0fr→1fr so no height jank.
* Hovering lifts the receipt tile; reduced motion keeps color, drops the lift.
*/
export function ExpenseCard({
merchant,
amount,
currency = "USD",
date,
category,
status = "pending",
policyFlag,
hasReceipt = false,
cardLast4,
className,
...props
}: ExpenseCardProps) {
const reduced = useReducedMotion();
const meta = CATEGORY_META[category];
const Icon = meta.icon;
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
const statusStyles: Record<ExpenseStatus, string> = {
approved: "bg-success/10 text-success",
pending: "bg-muted text-muted-foreground",
flagged: "bg-warning/15 text-warning",
};
const statusLabel: Record<ExpenseStatus, string> = {
approved: "Approved",
pending: "Pending",
flagged: "Needs review",
};
return (
<div
className={cn(
"w-full max-w-md overflow-hidden rounded-xl bg-card shadow-border",
className,
)}
{...props}
>
<div className="flex items-center gap-3 p-3">
<div
className={cn(
"relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border",
hasReceipt
? "bg-gradient-to-br from-secondary to-muted"
: "bg-muted",
)}
>
{hasReceipt ? (
<Receipt className="size-5 text-muted-foreground" aria-hidden />
) : (
<Receipt
className="size-5 text-muted-foreground/40"
aria-hidden
/>
)}
{hasReceipt && (
<span
aria-hidden
className="absolute right-0 bottom-0 flex size-4 items-center justify-center rounded-tl-md bg-success text-success-foreground"
>
<Check className="size-3" />
</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-medium">{merchant}</p>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[13px] text-muted-foreground">
<Icon className="size-3.5 shrink-0" aria-hidden />
<span>{meta.label}</span>
<span aria-hidden>·</span>
<span className="tabular-nums">{date}</span>
{cardLast4 && (
<>
<span aria-hidden>·</span>
<span className="tabular-nums">•••• {cardLast4}</span>
</>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
<span className="text-sm font-semibold tabular-nums">{money}</span>
<span
className={cn(
"rounded-full px-2 py-0.5 text-[11px] font-medium",
statusStyles[status],
)}
>
{statusLabel[status]}
</span>
</div>
</div>
<div
className={cn(
"grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
policyFlag ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<AnimatePresence initial={false}>
{policyFlag && (
<motion.div
initial={reduced ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: [0.22, 1, 0.36, 1] }}
className="flex items-center gap-2 border-t bg-warning/10 px-3 py-2 text-[13px] text-warning"
>
<AlertTriangle className="size-3.5 shrink-0" aria-hidden />
<span>{policyFlag}</span>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
}