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 (
+
+ );
+ })}
+
+
+
+ >
+ );
+}
diff --git a/src/components/frontend/banking/BankingOverview.tsx b/src/components/frontend/banking/BankingOverview.tsx
new file mode 100644
index 0000000..26e1731
--- /dev/null
+++ b/src/components/frontend/banking/BankingOverview.tsx
@@ -0,0 +1,172 @@
+"use client";
+
+import type { ReactNode } from "react";
+import Link from "next/link";
+import type { BankAccount } from "@/payload-types";
+import { LandmarkIcon, WalletIcon } from "lucide-react";
+import { Item, ItemContent, ItemHeader, ItemMedia, ItemTitle } from "@/components/ui/item";
+import { formatAmount } from "@/lib/banking/format";
+import { AccountCard } from "@/components/frontend/banking/AccountCard";
+import { CreateAccountDialog } from "@/components/frontend/banking/CreateAccountDialog";
+import { MyWalletCard } from "@/components/frontend/banking/MyWalletCard";
+
+interface BankingOverviewProps {
+ accounts: BankAccount[];
+ currentUserId: number;
+ isManager: boolean;
+ factions: { id: number; name: string }[];
+}
+
+function Section({
+ title,
+ count,
+ children,
+}: {
+ title: string;
+ count: number;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+ {title}
+
+
+ {count} account{count !== 1 ? "s" : ""}
+
+
+ {count === 0 ? (
+ None.
+ ) : (
+
+ {children}
+
+ )}
+
+ );
+}
+
+export function BankingOverview({
+ accounts,
+ currentUserId,
+ isManager,
+ factions,
+}: BankingOverviewProps) {
+ const treasury = accounts.filter((a) => a.accountType === "treasury");
+ const faction = accounts.filter((a) => a.accountType === "faction");
+ const personal = accounts.filter((a) => a.accountType === "personal");
+
+ const myAccount = personal.find((a) => {
+ const ownerId = typeof a.ownerUser === "object" ? a.ownerUser?.id : a.ownerUser;
+ return ownerId === currentUserId;
+ });
+
+ const unitFunds = [...treasury, ...faction].reduce(
+ (sum, a) => sum + (a.balance ?? 0),
+ 0,
+ );
+ const currency =
+ accounts.length > 0 && typeof accounts[0].currency === "object"
+ ? accounts[0].currency
+ : null;
+
+ const accountTypeLabels: Record = {
+ treasury: "Treasury",
+ faction: "Faction",
+ personal: "Personal",
+ };
+
+ return (
+
+
+ -
+
+
+
+
+
+ Unit funds
+
+
+ {formatAmount(unitFunds, currency)}
+
+
+
+
+ -
+
+
+
+
+
+ Accounts
+
+
+ {accounts.length}
+
+
+
+
+ {myAccount ? (
+
+ -
+
+
+
+
+
+ My wallet
+
+
+ {formatAmount(myAccount.balance ?? 0, currency)}
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {treasury.map((account) => (
+
+ ))}
+
+
+
+ {faction.map((account) => (
+
+ ))}
+
+
+
+ {personal.map((account) => (
+
+ ))}
+
+
+ {accounts.length === 0 && (
+
+ No accounts yet. Create your first account to get started.
+ {isManager ? "" : " (Ask logistics personnel to set up the unit treasury.)"}
+
+ )}
+
+
+ Account types: {Object.values(accountTypeLabels).join(" · ")}.
+ Balances are denominated in the main currency set in Game Rules.
+
+
+ );
+}
diff --git a/src/components/frontend/banking/CreateAccountDialog.tsx b/src/components/frontend/banking/CreateAccountDialog.tsx
new file mode 100644
index 0000000..1ab04c7
--- /dev/null
+++ b/src/components/frontend/banking/CreateAccountDialog.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { PlusIcon } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { createBankAccount } from "@/app/(frontend)/logistics/banking/actions";
+
+interface CreateAccountDialogProps {
+ isManager: boolean;
+ factions: { id: number; name: string }[];
+}
+
+type AccountType = "treasury" | "faction" | "personal";
+
+export function CreateAccountDialog({ isManager, factions }: CreateAccountDialogProps) {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [name, setName] = useState("");
+ const [accountType, setAccountType] = useState("personal");
+ const [ownerFactionId, setOwnerFactionId] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function handleSubmit() {
+ if (!name.trim()) {
+ setError("Account name is required.");
+ return;
+ }
+ if (accountType === "faction" && !ownerFactionId) {
+ setError("Select an owner faction.");
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ const res = await createBankAccount({
+ name: name.trim(),
+ accountType,
+ ownerFactionId: accountType === "faction" ? Number(ownerFactionId) : undefined,
+ });
+ if (res.success) {
+ setOpen(false);
+ setName("");
+ setAccountType("personal");
+ setOwnerFactionId("");
+ router.refresh();
+ } else {
+ setError(res.error ?? "Failed to create account.");
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/frontend/banking/LedgerTable.tsx b/src/components/frontend/banking/LedgerTable.tsx
new file mode 100644
index 0000000..599d91f
--- /dev/null
+++ b/src/components/frontend/banking/LedgerTable.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import type { LedgerEntry } from "@/payload-types";
+import { formatDate } from "@/lib/banking/format";
+
+const typeMeta: Record = {
+ 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" },
+};
+
+export function LedgerTable({ entries }: { entries: LedgerEntry[] }) {
+ if (entries.length === 0) {
+ return (
+
+ No ledger entries yet.
+
+ );
+ }
+
+ return (
+
+
+
+
+ |
+ Date
+ |
+
+ Type
+ |
+
+ Memo
+ |
+
+ Amount
+ |
+
+ Balance
+ |
+
+
+
+ {entries.map((entry) => {
+ const meta = typeMeta[entry.type] ?? {
+ label: entry.type,
+ cls: "text-muted-foreground",
+ };
+ const amount = entry.amount ?? 0;
+ return (
+
+ |
+ {formatDate(entry.timestamp)}
+ |
+
+ {meta.label}
+ |
+ {entry.memo} |
+ = 0 ? "text-emerald-400" : "text-red-400"}`}
+ >
+ {amount >= 0 ? "+" : ""}
+ {amount.toLocaleString()}
+ |
+
+ {(entry.balanceAfter ?? 0).toLocaleString()}
+ |
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/components/frontend/banking/MyWalletCard.tsx b/src/components/frontend/banking/MyWalletCard.tsx
new file mode 100644
index 0000000..7dcd543
--- /dev/null
+++ b/src/components/frontend/banking/MyWalletCard.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { WalletIcon } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { ensureMyAccount } from "@/app/(frontend)/logistics/banking/actions";
+
+export function MyWalletCard() {
+ const router = useRouter();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function handleCreate() {
+ setLoading(true);
+ setError(null);
+ const res = await ensureMyAccount();
+ if (res.success) {
+ router.push(`/logistics/banking/${res.data}`);
+ router.refresh();
+ } else {
+ setError(res.error ?? "Failed to create wallet.");
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+ You don't have a personal account yet.
+
+ {error &&
{error}
}
+
+
+ );
+}
diff --git a/src/components/frontend/blocks/AppSidebar.tsx b/src/components/frontend/blocks/AppSidebar.tsx
index e745665..d3eea0c 100644
--- a/src/components/frontend/blocks/AppSidebar.tsx
+++ b/src/components/frontend/blocks/AppSidebar.tsx
@@ -80,6 +80,10 @@ const data = {
title: "Deployed Vehicles",
url: "/logistics/game-vehicles",
},
+ {
+ title: "Banking",
+ url: "/logistics/banking",
+ },
],
},
],