1
0
Fork 0

refactor(banking): extract shared UI constants for status styling and transaction colors

This commit is contained in:
Jason Fraley 2026-08-12 23:12:57 -04:00
parent 2dff16a291
commit 034f0af5f3
4 changed files with 168 additions and 46 deletions

View file

@ -12,6 +12,11 @@ import {
} from "@/components/ui/item"; } from "@/components/ui/item";
import { LandmarkIcon, UsersIcon, WalletIcon } from "lucide-react"; import { LandmarkIcon, UsersIcon, WalletIcon } from "lucide-react";
import { formatAmount } from "@/lib/banking/format"; import { formatAmount } from "@/lib/banking/format";
import {
accountStatusBase,
accountStatusClass,
type AccountStatus,
} from "@/lib/banking/ui";
const accountTypeMeta = { const accountTypeMeta = {
treasury: { treasury: {
@ -38,12 +43,6 @@ export function accountTypeInfo(accountType: string) {
return accountTypeMeta[accountType as keyof typeof accountTypeMeta] ?? accountTypeMeta.personal; return accountTypeMeta[accountType as keyof typeof accountTypeMeta] ?? accountTypeMeta.personal;
} }
const statusColor: Record<string, string> = {
open: "bg-green-500/20 text-green-400",
frozen: "bg-amber-500/20 text-amber-400",
closed: "bg-muted text-muted-foreground",
};
export function AccountCard({ account }: { account: BankAccount }) { export function AccountCard({ account }: { account: BankAccount }) {
const meta = accountTypeInfo(account.accountType); const meta = accountTypeInfo(account.accountType);
const Icon = meta.icon; const Icon = meta.icon;
@ -58,6 +57,8 @@ export function AccountCard({ account }: { account: BankAccount }) {
? account.ownerFaction?.name ? account.ownerFaction?.name
: null; : null;
const statusKey = (account.status ?? "open") as AccountStatus;
return ( return (
<Link href={`/logistics/banking/${account.id}`} className="block"> <Link href={`/logistics/banking/${account.id}`} className="block">
<Item <Item
@ -71,9 +72,9 @@ export function AccountCard({ account }: { account: BankAccount }) {
<ItemHeader> <ItemHeader>
<ItemTitle>{account.name}</ItemTitle> <ItemTitle>{account.name}</ItemTitle>
<span <span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColor[account.status] ?? "bg-muted text-muted-foreground"}`} className={`${accountStatusBase} ${accountStatusClass[statusKey] ?? accountStatusClass.open}`}
> >
{account.status} {statusKey}
</span> </span>
</ItemHeader> </ItemHeader>
<ItemDescription> <ItemDescription>

View file

@ -10,16 +10,15 @@ import {
ItemTitle, ItemTitle,
} from "@/components/ui/item"; } from "@/components/ui/item";
import { formatAmount } from "@/lib/banking/format"; import { formatAmount } from "@/lib/banking/format";
import {
accountStatusBase,
accountStatusClass,
type AccountStatus,
} from "@/lib/banking/ui";
import { accountTypeInfo } from "@/components/frontend/banking/AccountCard"; import { accountTypeInfo } from "@/components/frontend/banking/AccountCard";
import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog"; import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog";
import { LedgerTable } from "@/components/frontend/banking/LedgerTable"; import { LedgerTable } from "@/components/frontend/banking/LedgerTable";
const statusColor: Record<string, string> = {
open: "bg-green-500/20 text-green-400",
frozen: "bg-amber-500/20 text-amber-400",
closed: "bg-muted text-muted-foreground",
};
interface AccountDetailProps { interface AccountDetailProps {
account: BankAccount; account: BankAccount;
ledger: LedgerEntry[]; ledger: LedgerEntry[];
@ -36,6 +35,7 @@ export function AccountDetail({
const meta = accountTypeInfo(account.accountType); const meta = accountTypeInfo(account.accountType);
const Icon = meta.icon; const Icon = meta.icon;
const currency = typeof account.currency === "object" ? account.currency : null; const currency = typeof account.currency === "object" ? account.currency : null;
const statusKey = (account.status ?? "open") as AccountStatus;
const owner = const owner =
account.accountType === "personal" account.accountType === "personal"
@ -64,9 +64,9 @@ export function AccountDetail({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColor[account.status] ?? "bg-muted text-muted-foreground"}`} className={`${accountStatusBase} ${accountStatusClass[statusKey] ?? accountStatusClass.open}`}
> >
{account.status} {statusKey}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{meta.label} {meta.label}
@ -75,7 +75,7 @@ export function AccountDetail({
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-xs text-muted-foreground uppercase tracking-wider"> <p className="font-mono text-[10px] text-muted-foreground uppercase tracking-wider">
Balance Balance
</p> </p>
<p className="text-2xl font-semibold font-mono"> <p className="text-2xl font-semibold font-mono">

View file

@ -2,23 +2,26 @@
import type { LedgerEntry } from "@/payload-types"; import type { LedgerEntry } from "@/payload-types";
import { formatDate } from "@/lib/banking/format"; import { formatDate } from "@/lib/banking/format";
import { txAmountClass, txAmountPrefix, txTypeColor, txTypeFlow } from "@/lib/banking/ui";
const typeMeta: Record<string, { label: string; cls: string }> = { // Title-case label per type — kept local because the table column header is
deposit: { label: "Deposit", cls: "text-emerald-400" }, // "Type" (title case) and matches title-case values; the ActivityFeed uses
withdrawal: { label: "Withdrawal", cls: "text-red-400" }, // uppercase mono labels per the terminal-log aesthetic. Each surface keeps
transfer: { label: "Transfer", cls: "text-blue-400" }, // the case that fits its chrome.
payment: { label: "Payment", cls: "text-red-400" }, const txTypeLabel: Record<string, string> = {
fee: { label: "Fee", cls: "text-muted-foreground" }, deposit: "Deposit",
salary: { label: "Salary", cls: "text-green-400" }, withdrawal: "Withdrawal",
adjustment: { label: "Adjustment", cls: "text-amber-400" }, transfer: "Transfer",
payment: "Payment",
fee: "Fee",
salary: "Salary",
adjustment: "Adjustment",
}; };
export function LedgerTable({ entries }: { entries: LedgerEntry[] }) { export function LedgerTable({ entries }: { entries: LedgerEntry[] }) {
if (entries.length === 0) { if (entries.length === 0) {
return ( return (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">No ledger entries yet.</p>
No ledger entries yet.
</p>
); );
} }
@ -27,44 +30,51 @@ export function LedgerTable({ entries }: { entries: LedgerEntry[] }) {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="bg-muted/50 border-b border-border"> <tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground"> <th className="text-left px-4 py-2.5 font-mono font-semibold uppercase text-[10px] tracking-wider text-muted-foreground">
Date Date
</th> </th>
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground"> <th className="text-left px-4 py-2.5 font-mono font-semibold uppercase text-[10px] tracking-wider text-muted-foreground">
Type Type
</th> </th>
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground"> <th className="text-left px-4 py-2.5 font-mono font-semibold uppercase text-[10px] tracking-wider text-muted-foreground">
Memo Memo
</th> </th>
<th className="text-right px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground"> <th className="text-right px-4 py-2.5 font-mono font-semibold uppercase text-[10px] tracking-wider text-muted-foreground">
Amount Amount
</th> </th>
<th className="text-right px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground"> <th className="text-right px-4 py-2.5 font-mono font-semibold uppercase text-[10px] tracking-wider text-muted-foreground">
Balance Balance
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{entries.map((entry) => { {entries.map((entry) => {
const meta = typeMeta[entry.type] ?? { const colorCls = txTypeColor[entry.type] ?? "text-muted-foreground";
label: entry.type, const label = txTypeLabel[entry.type] ?? entry.type;
cls: "text-muted-foreground", const flow = txTypeFlow[entry.type] ?? "neutral";
};
const amount = entry.amount ?? 0; const amount = entry.amount ?? 0;
const absAmount = Math.abs(amount);
// If the entry's own sign disagrees with the canonical flow (e.g.
// a transfer into this account is stored as +amount), trust the
// entry's actual sign — it reflects the perspective of THIS
// account, which the canonical flow cannot know.
const useEntrySign = amount !== 0 && Math.sign(amount) < 0;
const prefix = useEntrySign
? txAmountPrefix("out")
: txAmountPrefix(flow);
const amountCls = useEntrySign
? txAmountClass("out")
: txAmountClass(flow);
return ( return (
<tr key={entry.id} className="border-b border-border/50"> <tr key={entry.id} className="border-b border-border/50">
<td className="px-4 py-2 text-muted-foreground"> <td className="px-4 py-2 text-muted-foreground">
{formatDate(entry.timestamp)} {formatDate(entry.timestamp)}
</td> </td>
<td className={`px-4 py-2 font-medium ${meta.cls}`}> <td className={`px-4 py-2 ${colorCls} font-medium`}>{label}</td>
{meta.label}
</td>
<td className="px-4 py-2 text-muted-foreground">{entry.memo}</td> <td className="px-4 py-2 text-muted-foreground">{entry.memo}</td>
<td <td className={`px-4 py-2 text-right font-mono ${amountCls}`}>
className={`px-4 py-2 text-right font-mono ${amount >= 0 ? "text-emerald-400" : "text-red-400"}`} {prefix}
> {absAmount.toLocaleString()}
{amount >= 0 ? "+" : ""}
{amount.toLocaleString()}
</td> </td>
<td className="px-4 py-2 text-right font-mono"> <td className="px-4 py-2 text-right font-mono">
{(entry.balanceAfter ?? 0).toLocaleString()} {(entry.balanceAfter ?? 0).toLocaleString()}

111
src/lib/banking/ui.ts Normal file
View file

@ -0,0 +1,111 @@
/**
* Shared visual tokens for the banking UI family.
*
* Used by:
* - WalletHero, AccountCard, AccountDetail (account status chips)
* - ActivityFeed, LedgerTable (transaction type colors and amount signs)
* - BankTransactionDialog (trigger button styling via shared className)
*
* Editing any value here updates every banking component consistently.
* Keep tokens minimal and semantic — no one-off custom classes here.
*/
// --- Account status chip -----------------------------------------------------
export type AccountStatus = "open" | "frozen" | "closed";
export const accountStatusClass: Record<AccountStatus, string> = {
open: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
frozen: "bg-amber-500/15 text-amber-400 border-amber-500/30",
closed: "bg-muted text-muted-foreground border-border",
};
// Shared base class for the status chip — applied alongside accountStatusClass.
export const accountStatusBase =
"text-[10px] font-mono font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border";
// --- Transaction type tokens -------------------------------------------------
// Tailwind text-color class per transaction type — used by ActivityFeed and
// LedgerTable so the two transaction displays share one color source of truth.
export const txTypeColor: Record<string, string> = {
deposit: "text-emerald-400",
withdrawal: "text-red-400",
transfer: "text-blue-400",
payment: "text-red-400",
fee: "text-muted-foreground",
salary: "text-emerald-400",
adjustment: "text-amber-400",
};
// Flow direction — drives amount sign and color. Mirrors txTypeColor semantics
// but groups types by their effect on the account being viewed.
export type TxFlow = "in" | "out" | "neutral";
export const txTypeFlow: Record<string, TxFlow> = {
deposit: "in",
withdrawal: "out",
transfer: "neutral",
payment: "out",
fee: "out",
salary: "in",
adjustment: "neutral",
};
export function txAmountClass(flow: TxFlow): string {
if (flow === "in") return "text-emerald-400";
if (flow === "out") return "text-red-400";
return "text-blue-400";
}
export function txAmountPrefix(flow: TxFlow): string {
if (flow === "in") return "+";
if (flow === "out") return "\u2212"; // typographic minus (U+2212), aligns with mono digits
return "";
}
// --- Timestamp formatters -----------------------------------------------------
/**
* Compact 24h timestamp for transaction feed rows:
* - today → "HH:MM" (24h, local time)
* - older → "MMM DD" (uppercase, local time)
*
* Used by ActivityFeed. Keep in sync with formatLastActivity below — both
* read local time, never UTC, so a transaction rendered in the feed and the
* "last activity" line on the hero never disagree by a timezone offset.
*/
export function formatTxTimestamp(ts: string): string {
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return "—";
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
const hh = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
}
return date
.toLocaleDateString("en-US", { month: "short", day: "numeric" })
.toUpperCase();
}
/**
* Absolute "last activity" timestamp for the WalletHero:
* "YYYY-MM-DD HH:MM" in LOCAL time (not UTC).
*
* The previous implementation used `date.toISOString()` which rendered UTC,
* causing the hero's "LAST ACTIVITY" line to be hours ahead of the same
* transaction rendered in ActivityFeed (which reads local time).
*/
export function formatLastActivity(ts?: string): string {
if (!ts) return "NO ACTIVITY YET";
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return "NO ACTIVITY YET";
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const min = String(date.getMinutes()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${min}`;
}