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