feat(banking): add WalletHero and ActivityFeed components for banking overview
This commit is contained in:
parent
034f0af5f3
commit
9210b7bbec
2 changed files with 308 additions and 0 deletions
156
src/components/frontend/banking/ActivityFeed.tsx
Normal file
156
src/components/frontend/banking/ActivityFeed.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/components/frontend/banking/WalletHero.tsx
Normal file
152
src/components/frontend/banking/WalletHero.tsx
Normal 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}><OPERATOR WALLET></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}><OPERATOR WALLET></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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue