1
0
Fork 0

feat(banking): add WalletHero and ActivityFeed components for banking overview

This commit is contained in:
Jason Fraley 2026-08-12 23:13:11 -04:00
parent 034f0af5f3
commit 9210b7bbec
2 changed files with 308 additions and 0 deletions

View file

@ -0,0 +1,156 @@
"use client";
import Link from "next/link";
import type { BankAccount, BankTransaction, Resource, User } from "@/payload-types";
import {
ArrowDownLeftIcon,
ArrowRightLeftIcon,
ArrowUpRightIcon,
} from "lucide-react";
import { currencyLabel } from "@/lib/banking/format";
import {
formatTxTimestamp,
txAmountClass,
txAmountPrefix,
txTypeColor,
txTypeFlow,
type TxFlow,
} from "@/lib/banking/ui";
interface ActivityFeedProps {
transactions: BankTransaction[];
currencyResource: Resource | null;
}
// Icon per type — kept local because the icons are React components used only
// by this feed (the LedgerTable renders a tabular view without icons).
const txTypeIcon: Record<string, typeof ArrowDownLeftIcon> = {
deposit: ArrowDownLeftIcon,
withdrawal: ArrowUpRightIcon,
transfer: ArrowRightLeftIcon,
payment: ArrowUpRightIcon,
fee: ArrowDownLeftIcon,
salary: ArrowDownLeftIcon,
adjustment: ArrowRightLeftIcon,
};
// Uppercase mono label per type (terminal-aesthetic).
const txTypeLabel: Record<string, string> = {
deposit: "DEPOSIT",
withdrawal: "WITHDRAW",
transfer: "TRANSFER",
payment: "PAYMENT",
fee: "FEE",
salary: "SALARY",
adjustment: "ADJUST",
};
function resolveAccountLabel(
account: (number | null) | BankAccount | undefined,
): { label: string; id: number | null } {
if (!account) return { label: "—", id: null };
if (typeof account === "object") {
return { label: account.name ?? `#${account.id}`, id: account.id };
}
return { label: `#${account}`, id: account };
}
function resolveActorLabel(actor: (number | null) | User | undefined): string {
if (!actor) return "";
if (typeof actor === "object") {
return actor.displayName ?? actor.username;
}
return `#${actor}`;
}
export function ActivityFeed({ transactions, currencyResource }: ActivityFeedProps) {
const cur = currencyLabel(currencyResource);
if (transactions.length === 0) {
return (
<div className="rounded-lg border border-border/60 bg-muted/20 py-10 flex items-center justify-center">
<p className="font-mono text-xs uppercase tracking-wider text-muted-foreground/70">
{"> NO TRANSACTIONS YET"}
</p>
</div>
);
}
return (
<div className="rounded-lg border border-border/60 overflow-hidden">
<ul className="divide-y divide-border/40">
{transactions.map((tx) => {
const flow: TxFlow = txTypeFlow[tx.type] ?? "neutral";
const colorCls = txTypeColor[tx.type] ?? "text-muted-foreground";
const Icon = txTypeIcon[tx.type] ?? ArrowRightLeftIcon;
const label = txTypeLabel[tx.type] ?? tx.type.toUpperCase();
const from = resolveAccountLabel(tx.fromAccount);
const to = resolveAccountLabel(tx.toAccount);
const actor = resolveActorLabel(tx.actor);
const targetAccountId = to.id ?? from.id;
const href =
targetAccountId != null
? `/logistics/banking/${targetAccountId}`
: null;
const amount = tx.amount ?? 0;
const signed = `${txAmountPrefix(flow)}${amount.toLocaleString()}`;
const RowInner = (
<div className="flex items-start gap-3">
<Icon className={`size-4 shrink-0 mt-0.5 ${colorCls}`} />
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/70">
{formatTxTimestamp(tx.timestamp)}
</span>
<span className={`font-mono text-[10px] uppercase tracking-wider ${colorCls}`}>
{label}
</span>
</div>
<div className="font-mono text-xs text-muted-foreground truncate">
{from.label}
<span className="text-muted-foreground/50"> → </span>
{to.label}
</div>
{tx.memo && (
<p className="text-[11px] text-muted-foreground/70 italic truncate">
{tx.memo}
</p>
)}
{actor && (
<p className="text-[10px] text-muted-foreground/50">by {actor}</p>
)}
</div>
<div
className={`font-mono text-sm tabular-nums shrink-0 ml-auto pr-1 ${txAmountClass(flow)}`}
>
{signed}
{cur && (
<span className="ml-1 text-[10px] text-muted-foreground/60">
{cur}
</span>
)}
</div>
</div>
);
return (
<li key={tx.id}>
{href ? (
<Link
href={href}
prefetch={false}
className="block px-3 py-2.5 transition-colors hover:bg-muted/40 focus-visible:bg-muted/40 focus-visible:outline-none"
>
{RowInner}
</Link>
) : (
<div className="block px-3 py-2.5">{RowInner}</div>
)}
</li>
);
})}
</ul>
</div>
);
}

View file

@ -0,0 +1,152 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ChevronRightIcon, WalletIcon } from "lucide-react";
import type { BankAccount, Resource } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { formatAmount } from "@/lib/banking/format";
import {
accountStatusBase,
accountStatusClass,
formatLastActivity,
type AccountStatus,
} from "@/lib/banking/ui";
import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog";
import { ensureMyAccount } from "@/app/(frontend)/logistics/banking/actions";
interface WalletHeroProps {
account: BankAccount | null;
currentUser: { id: number; username: string; displayName?: string } | null;
currencyResource: Resource | null;
}
// Shared terminal-aesthetic class for small uppercase mono labels in the hero.
const labelCls = "font-mono text-[10px] uppercase tracking-wider text-muted-foreground";
export function WalletHero({
account,
currentUser,
currencyResource,
}: WalletHeroProps) {
const router = useRouter();
const [initializing, setInitializing] = useState(false);
const [initError, setInitError] = useState<string | null>(null);
async function handleInitialize() {
setInitializing(true);
setInitError(null);
const res = await ensureMyAccount();
if (res.success) {
router.push(`/logistics/banking/${res.data}`);
router.refresh();
} else {
setInitError(res.error ?? "Failed to initialize wallet.");
setInitializing(false);
}
}
// Empty state — terminal-flavored "WALLET NOT INITIALIZED"
if (!account || !currentUser) {
return (
<section className="rounded-lg border border-blue-500/25 bg-blue-500/[0.03] p-5">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<WalletIcon className="size-4 text-blue-400/80" />
<span className={labelCls}>&lt;OPERATOR WALLET&gt;</span>
</div>
<p className="font-mono text-sm text-muted-foreground/80">
{"> WALLET NOT INITIALIZED"}
</p>
<p className="font-mono text-xs text-muted-foreground/60">
Run initialization to provision your personal account.
</p>
{initError && (
<p className="font-mono text-xs text-red-400">
{"> ERROR: "}
{initError}
</p>
)}
<div>
<Button
size="sm"
onClick={handleInitialize}
disabled={initializing}
className="font-mono text-xs uppercase tracking-wider"
>
{initializing ? "> INITIALIZING…" : "> INITIALIZE"}
</Button>
</div>
</div>
</section>
);
}
const accountId = account.id;
const lastActivity = formatLastActivity(account.updatedAt);
const currency =
typeof account.currency === "object" ? account.currency : currencyResource;
const operatorLabel = currentUser.displayName ?? currentUser.username;
const statusKey = (account.status ?? "open") as AccountStatus;
// otherAccounts requires names for the transfer destination select.
// We only know this wallet here; the BankTransactionDialog receives an empty
// list — transfer mode will prompt the user to use the account detail page.
// Deposits/withdrawals work standalone on the hero.
const otherAccounts: { id: number; name: string }[] = [];
return (
<section className="rounded-lg border border-blue-500/25 bg-blue-500/[0.03] p-5">
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<WalletIcon className="size-4 text-blue-400/80" />
<span className={labelCls}>&lt;OPERATOR WALLET&gt;</span>
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-muted-foreground/90">
@{operatorLabel}
</span>
<span
className={`${accountStatusBase} ${accountStatusClass[statusKey] ?? accountStatusClass.open}`}
>
{statusKey}
</span>
</div>
</div>
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/50">
ID #{accountId.toString().padStart(6, "0")}
</span>
</div>
<div className="flex flex-col gap-1">
<p className="text-4xl md:text-5xl font-bold font-mono tracking-tight">
{formatAmount(account.balance ?? 0, currency)}
</p>
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/60">
LAST ACTIVITY {lastActivity}
</p>
</div>
<div className="flex flex-wrap items-center gap-2 pt-3 border-t border-blue-500/15">
<BankTransactionDialog
accountId={accountId}
otherAccounts={otherAccounts}
/>
<Link href={`/logistics/banking/${accountId}`} prefetch={false}>
<Button
size="sm"
variant="outline"
className="font-mono text-xs uppercase tracking-wider"
>
{"> LEDGER"}
<ChevronRightIcon className="size-3.5" />
</Button>
</Link>
</div>
</div>
</section>
);
}