refactor(banking): extract shared UI constants for status styling and transaction colors
This commit is contained in:
parent
2dff16a291
commit
034f0af5f3
4 changed files with 168 additions and 46 deletions
|
|
@ -12,6 +12,11 @@ import {
|
|||
} from "@/components/ui/item";
|
||||
import { LandmarkIcon, UsersIcon, WalletIcon } from "lucide-react";
|
||||
import { formatAmount } from "@/lib/banking/format";
|
||||
import {
|
||||
accountStatusBase,
|
||||
accountStatusClass,
|
||||
type AccountStatus,
|
||||
} from "@/lib/banking/ui";
|
||||
|
||||
const accountTypeMeta = {
|
||||
treasury: {
|
||||
|
|
@ -38,12 +43,6 @@ export function accountTypeInfo(accountType: string) {
|
|||
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 }) {
|
||||
const meta = accountTypeInfo(account.accountType);
|
||||
const Icon = meta.icon;
|
||||
|
|
@ -58,6 +57,8 @@ export function AccountCard({ account }: { account: BankAccount }) {
|
|||
? account.ownerFaction?.name
|
||||
: null;
|
||||
|
||||
const statusKey = (account.status ?? "open") as AccountStatus;
|
||||
|
||||
return (
|
||||
<Link href={`/logistics/banking/${account.id}`} className="block">
|
||||
<Item
|
||||
|
|
@ -71,9 +72,9 @@ export function AccountCard({ account }: { account: BankAccount }) {
|
|||
<ItemHeader>
|
||||
<ItemTitle>{account.name}</ItemTitle>
|
||||
<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>
|
||||
</ItemHeader>
|
||||
<ItemDescription>
|
||||
|
|
|
|||
|
|
@ -10,16 +10,15 @@ import {
|
|||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { formatAmount } from "@/lib/banking/format";
|
||||
import {
|
||||
accountStatusBase,
|
||||
accountStatusClass,
|
||||
type AccountStatus,
|
||||
} from "@/lib/banking/ui";
|
||||
import { accountTypeInfo } from "@/components/frontend/banking/AccountCard";
|
||||
import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog";
|
||||
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 {
|
||||
account: BankAccount;
|
||||
ledger: LedgerEntry[];
|
||||
|
|
@ -36,6 +35,7 @@ export function AccountDetail({
|
|||
const meta = accountTypeInfo(account.accountType);
|
||||
const Icon = meta.icon;
|
||||
const currency = typeof account.currency === "object" ? account.currency : null;
|
||||
const statusKey = (account.status ?? "open") as AccountStatus;
|
||||
|
||||
const owner =
|
||||
account.accountType === "personal"
|
||||
|
|
@ -64,9 +64,9 @@ export function AccountDetail({
|
|||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className="text-xs text-muted-foreground">
|
||||
{meta.label}
|
||||
|
|
@ -75,7 +75,7 @@ export function AccountDetail({
|
|||
</div>
|
||||
</div>
|
||||
<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
|
||||
</p>
|
||||
<p className="text-2xl font-semibold font-mono">
|
||||
|
|
|
|||
|
|
@ -2,23 +2,26 @@
|
|||
|
||||
import type { LedgerEntry } from "@/payload-types";
|
||||
import { formatDate } from "@/lib/banking/format";
|
||||
import { txAmountClass, txAmountPrefix, txTypeColor, txTypeFlow } from "@/lib/banking/ui";
|
||||
|
||||
const typeMeta: Record<string, { label: string; cls: string }> = {
|
||||
deposit: { label: "Deposit", cls: "text-emerald-400" },
|
||||
withdrawal: { label: "Withdrawal", cls: "text-red-400" },
|
||||
transfer: { label: "Transfer", cls: "text-blue-400" },
|
||||
payment: { label: "Payment", cls: "text-red-400" },
|
||||
fee: { label: "Fee", cls: "text-muted-foreground" },
|
||||
salary: { label: "Salary", cls: "text-green-400" },
|
||||
adjustment: { label: "Adjustment", cls: "text-amber-400" },
|
||||
// Title-case label per type — kept local because the table column header is
|
||||
// "Type" (title case) and matches title-case values; the ActivityFeed uses
|
||||
// uppercase mono labels per the terminal-log aesthetic. Each surface keeps
|
||||
// the case that fits its chrome.
|
||||
const txTypeLabel: Record<string, string> = {
|
||||
deposit: "Deposit",
|
||||
withdrawal: "Withdrawal",
|
||||
transfer: "Transfer",
|
||||
payment: "Payment",
|
||||
fee: "Fee",
|
||||
salary: "Salary",
|
||||
adjustment: "Adjustment",
|
||||
};
|
||||
|
||||
export function LedgerTable({ entries }: { entries: LedgerEntry[] }) {
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No ledger entries yet.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground italic">No ledger entries yet.</p>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -27,44 +30,51 @@ export function LedgerTable({ entries }: { entries: LedgerEntry[] }) {
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<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
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
const meta = typeMeta[entry.type] ?? {
|
||||
label: entry.type,
|
||||
cls: "text-muted-foreground",
|
||||
};
|
||||
const colorCls = txTypeColor[entry.type] ?? "text-muted-foreground";
|
||||
const label = txTypeLabel[entry.type] ?? entry.type;
|
||||
const flow = txTypeFlow[entry.type] ?? "neutral";
|
||||
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 (
|
||||
<tr key={entry.id} className="border-b border-border/50">
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{formatDate(entry.timestamp)}
|
||||
</td>
|
||||
<td className={`px-4 py-2 font-medium ${meta.cls}`}>
|
||||
{meta.label}
|
||||
</td>
|
||||
<td className={`px-4 py-2 ${colorCls} font-medium`}>{label}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{entry.memo}</td>
|
||||
<td
|
||||
className={`px-4 py-2 text-right font-mono ${amount >= 0 ? "text-emerald-400" : "text-red-400"}`}
|
||||
>
|
||||
{amount >= 0 ? "+" : ""}
|
||||
{amount.toLocaleString()}
|
||||
<td className={`px-4 py-2 text-right font-mono ${amountCls}`}>
|
||||
{prefix}
|
||||
{absAmount.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono">
|
||||
{(entry.balanceAfter ?? 0).toLocaleString()}
|
||||
|
|
@ -76,4 +86,4 @@ export function LedgerTable({ entries }: { entries: LedgerEntry[] }) {
|
|||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
111
src/lib/banking/ui.ts
Normal file
111
src/lib/banking/ui.ts
Normal 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}`;
|
||||
}
|
||||
Loading…
Reference in a new issue