From 898c09e30271a1a84b44cd2c0040db791b975a2d Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sun, 2 Aug 2026 04:10:40 -0400 Subject: [PATCH] feat(banking): add banking overview and account detail UI --- .../logistics/banking/[id]/page.tsx | 87 ++++++++ src/app/(frontend)/logistics/banking/page.tsx | 64 ++++++ .../frontend/banking/AccountCard.tsx | 92 +++++++++ .../frontend/banking/AccountDetail.tsx | 107 ++++++++++ .../banking/BankTransactionDialog.tsx | 195 ++++++++++++++++++ .../frontend/banking/BankingOverview.tsx | 172 +++++++++++++++ .../frontend/banking/CreateAccountDialog.tsx | 146 +++++++++++++ .../frontend/banking/LedgerTable.tsx | 79 +++++++ .../frontend/banking/MyWalletCard.tsx | 47 +++++ src/components/frontend/blocks/AppSidebar.tsx | 4 + 10 files changed, 993 insertions(+) create mode 100644 src/app/(frontend)/logistics/banking/[id]/page.tsx create mode 100644 src/app/(frontend)/logistics/banking/page.tsx create mode 100644 src/components/frontend/banking/AccountCard.tsx create mode 100644 src/components/frontend/banking/AccountDetail.tsx create mode 100644 src/components/frontend/banking/BankTransactionDialog.tsx create mode 100644 src/components/frontend/banking/BankingOverview.tsx create mode 100644 src/components/frontend/banking/CreateAccountDialog.tsx create mode 100644 src/components/frontend/banking/LedgerTable.tsx create mode 100644 src/components/frontend/banking/MyWalletCard.tsx diff --git a/src/app/(frontend)/logistics/banking/[id]/page.tsx b/src/app/(frontend)/logistics/banking/[id]/page.tsx new file mode 100644 index 0000000..a005eb2 --- /dev/null +++ b/src/app/(frontend)/logistics/banking/[id]/page.tsx @@ -0,0 +1,87 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { headers as nextHeaders } from "next/headers"; +import { notFound } from "next/navigation"; +import type { BankAccount, LedgerEntry } from "@/payload-types"; +import { AccountDetail } from "@/components/frontend/banking/AccountDetail"; +import hasRoles from "@/utils/access-control/hasRoles"; +import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; + +export const metadata = { + title: "Account — Polaris Task Force", +}; + +interface AccountPageProps { + params: Promise<{ id: string }>; +} + +export default async function AccountPage({ params }: AccountPageProps) { + const { id } = await params; + const payload = await getPayload({ config }); + const { user } = await payload.auth({ + headers: await nextHeaders(), + canSetHeaders: false, + }); + + const account = await payload + .findByID({ + collection: "bank-accounts", + id: Number(id), + depth: 1, + }) + .catch(() => null); + + if (!account) { + notFound(); + } + + const typedAccount = account as unknown as BankAccount; + + const isManager = user + ? hasRoles(["admin", "developer"], user) || + (await hasLogisticsQualification(payload, user).catch(() => false)) + : false; + + const ownerId = + typeof typedAccount.ownerUser === "object" + ? typedAccount.ownerUser?.id + : typedAccount.ownerUser; + + const isOwner = + typedAccount.accountType === "personal" && + user !== null && + ownerId === user.id; + + if (!isManager && !isOwner) { + notFound(); + } + + const ledgerRes = await payload.find({ + collection: "ledger-entries", + where: { account: { equals: Number(id) } }, + sort: "-timestamp", + limit: 200, + depth: 0, + }); + const ledger = ledgerRes.docs as unknown as LedgerEntry[]; + + const allAccountsRes = await payload.find({ + collection: "bank-accounts", + limit: 500, + depth: 0, + }); + const otherAccounts = (allAccountsRes.docs as unknown as BankAccount[]) + .filter((a) => a.id !== typedAccount.id) + .map((a) => ({ id: a.id, name: a.name })); + + return ( +
+ +
+ ); +} diff --git a/src/app/(frontend)/logistics/banking/page.tsx b/src/app/(frontend)/logistics/banking/page.tsx new file mode 100644 index 0000000..516ca25 --- /dev/null +++ b/src/app/(frontend)/logistics/banking/page.tsx @@ -0,0 +1,64 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { headers as nextHeaders } from "next/headers"; +import type { BankAccount, Faction } from "@/payload-types"; +import { LandmarkIcon } from "lucide-react"; +import { BankingOverview } from "@/components/frontend/banking/BankingOverview"; +import hasRoles from "@/utils/access-control/hasRoles"; +import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; + +export const metadata = { + title: "Banking — Polaris Task Force", +}; + +export default async function BankingPage() { + const payload = await getPayload({ config }); + const { user } = await payload.auth({ + headers: await nextHeaders(), + canSetHeaders: false, + }); + + const accountsRes = await payload.find({ + collection: "bank-accounts", + sort: "name", + limit: 500, + depth: 1, + }); + const accounts = accountsRes.docs as unknown as BankAccount[]; + + const factionsRes = await payload.find({ + collection: "factions", + limit: 100, + depth: 0, + }); + const factions = (factionsRes.docs as unknown as Faction[]).map((f) => ({ + id: f.id, + name: f.name, + })); + + const isManager = user + ? hasRoles(["admin", "developer"], user) || + (await hasLogisticsQualification(payload, user).catch(() => false)) + : false; + + return ( +
+
+
+ +

Banking

+
+

+ Unit accounts, faction reserves, and personal wallets. +

+
+ + +
+ ); +} diff --git a/src/components/frontend/banking/AccountCard.tsx b/src/components/frontend/banking/AccountCard.tsx new file mode 100644 index 0000000..1a50111 --- /dev/null +++ b/src/components/frontend/banking/AccountCard.tsx @@ -0,0 +1,92 @@ +"use client"; + +import Link from "next/link"; +import type { BankAccount } from "@/payload-types"; +import { + Item, + ItemContent, + ItemDescription, + ItemHeader, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { LandmarkIcon, UsersIcon, WalletIcon } from "lucide-react"; +import { formatAmount } from "@/lib/banking/format"; + +const accountTypeMeta = { + treasury: { + label: "Treasury", + icon: LandmarkIcon, + accent: "text-blue-400", + iconBg: "bg-blue-500/10", + }, + faction: { + label: "Faction", + icon: UsersIcon, + accent: "text-amber-400", + iconBg: "bg-amber-500/10", + }, + personal: { + label: "Personal", + icon: WalletIcon, + accent: "text-emerald-400", + iconBg: "bg-emerald-500/10", + }, +} as const; + +export function accountTypeInfo(accountType: string) { + return accountTypeMeta[accountType as keyof typeof accountTypeMeta] ?? accountTypeMeta.personal; +} + +const statusColor: Record = { + 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; + const currency = typeof account.currency === "object" ? account.currency : null; + + const owner = + account.accountType === "personal" + ? typeof account.ownerUser === "object" && account.ownerUser + ? (account.ownerUser.displayName ?? account.ownerUser.username) + : null + : account.accountType === "faction" && typeof account.ownerFaction === "object" + ? account.ownerFaction?.name + : null; + + return ( + + + + + + + + {account.name} + + {account.status} + + + + {meta.label} + {owner ? ` · ${owner}` : ""} + +
+ + {formatAmount(account.balance ?? 0, currency)} + +
+
+
+ + ); +} diff --git a/src/components/frontend/banking/AccountDetail.tsx b/src/components/frontend/banking/AccountDetail.tsx new file mode 100644 index 0000000..c4f9aa3 --- /dev/null +++ b/src/components/frontend/banking/AccountDetail.tsx @@ -0,0 +1,107 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeftIcon } from "lucide-react"; +import type { BankAccount, LedgerEntry } from "@/payload-types"; +import { + Item, + ItemContent, + ItemHeader, + ItemTitle, +} from "@/components/ui/item"; +import { formatAmount } from "@/lib/banking/format"; +import { accountTypeInfo } from "@/components/frontend/banking/AccountCard"; +import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog"; +import { LedgerTable } from "@/components/frontend/banking/LedgerTable"; + +const statusColor: Record = { + 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[]; + otherAccounts: { id: number; name: string }[]; + canTransact: boolean; +} + +export function AccountDetail({ + account, + ledger, + otherAccounts, + canTransact, +}: AccountDetailProps) { + const meta = accountTypeInfo(account.accountType); + const Icon = meta.icon; + const currency = typeof account.currency === "object" ? account.currency : null; + + const owner = + account.accountType === "personal" + ? typeof account.ownerUser === "object" && account.ownerUser + ? (account.ownerUser.displayName ?? account.ownerUser.username) + : null + : account.accountType === "faction" && typeof account.ownerFaction === "object" + ? account.ownerFaction?.name + : null; + + return ( +
+ + + Back to Banking + + +
+
+
+ +

{account.name}

+
+
+ + {account.status} + + + {meta.label} + {owner ? ` · ${owner}` : ""} + +
+
+
+

+ Balance +

+

+ {formatAmount(account.balance ?? 0, currency)} +

+
+
+ + {canTransact && ( + + )} + + + + Ledger + + {ledger.length} entr{ledger.length !== 1 ? "ies" : "y"} + + + + + + +
+ ); +} diff --git a/src/components/frontend/banking/BankTransactionDialog.tsx b/src/components/frontend/banking/BankTransactionDialog.tsx new file mode 100644 index 0000000..906f4cb --- /dev/null +++ b/src/components/frontend/banking/BankTransactionDialog.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowDownLeftIcon, ArrowRightLeftIcon, ArrowUpRightIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + depositFunds, + transferFunds, + withdrawFunds, +} from "@/app/(frontend)/logistics/banking/actions"; + +type Mode = "deposit" | "withdraw" | "transfer"; + +const modeMeta: Record< + Mode, + { label: string; verb: string; description: string; icon: typeof ArrowDownLeftIcon } +> = { + deposit: { + label: "Deposit", + verb: "Deposit", + description: "Add funds to this account.", + icon: ArrowDownLeftIcon, + }, + withdraw: { + label: "Withdraw", + verb: "Withdraw", + description: "Remove funds from this account.", + icon: ArrowUpRightIcon, + }, + transfer: { + label: "Transfer", + verb: "Transfer", + description: "Move funds from this account to another account.", + icon: ArrowRightLeftIcon, + }, +}; + +interface BankTransactionDialogProps { + accountId: number; + otherAccounts: { id: number; name: string }[]; +} + +export function BankTransactionDialog({ + accountId, + otherAccounts, +}: BankTransactionDialogProps) { + const router = useRouter(); + const [mode, setMode] = useState("deposit"); + const [open, setOpen] = useState(false); + const [amount, setAmount] = useState(""); + const [toId, setToId] = useState(""); + const [memo, setMemo] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + function openMode(next: Mode) { + setMode(next); + setError(null); + setOpen(true); + } + + async function handleSubmit() { + const value = Number(amount); + if (!value || value <= 0) { + setError("Enter a valid amount."); + return; + } + if (mode === "transfer" && !toId) { + setError("Select a destination account."); + return; + } + setLoading(true); + setError(null); + const res = + mode === "deposit" + ? await depositFunds(accountId, value, memo || undefined) + : mode === "withdraw" + ? await withdrawFunds(accountId, value, memo || undefined) + : await transferFunds(accountId, Number(toId), value, memo || undefined); + + if (res.success) { + setOpen(false); + setAmount(""); + setToId(""); + setMemo(""); + router.refresh(); + } else { + setError(res.error ?? "Transaction failed."); + setLoading(false); + } + } + + const meta = modeMeta[mode]; + const Icon = meta.icon; + + return ( + <> +
+ {(Object.keys(modeMeta) as Mode[]).map((m) => { + const M = modeMeta[m]; + const Mc = M.icon; + return ( + + ); + })} +
+ + + + + + + {meta.verb} Funds + + {meta.description} + +
+
+ + setAmount(e.target.value)} + placeholder="0" + /> +
+ {mode === "transfer" && ( +
+ + +
+ )} +
+ +