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";
|
} 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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
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