diff --git a/apps/web/src/app/admin/_components/AdminNav.tsx b/apps/web/src/app/admin/_components/AdminNav.tsx new file mode 100644 index 00000000..5f5df7eb --- /dev/null +++ b/apps/web/src/app/admin/_components/AdminNav.tsx @@ -0,0 +1,35 @@ +"use client" + +import { usePathname } from "next/navigation" +import { useEffect, useRef } from "react" + +import { TabNav } from "@/components/ui/Tabs" + +export interface AdminNavLink { + href: string + label: string +} + +/** + * The admin section's tabs; the current one follows the URL. On a phone the + * row scrolls sideways, so the current tab is scrolled into view. + */ +export function AdminNav({ links }: { links: readonly AdminNavLink[] }) { + const pathname = usePathname() + const ref = useRef(null) + + useEffect(() => { + const current = ref.current?.querySelector('[aria-current="page"]') + const row = current?.closest("ul") + if (!current || !row || row.scrollWidth <= row.clientWidth) return + const tab = current.getBoundingClientRect() + const box = row.getBoundingClientRect() + row.scrollLeft += tab.left - box.left - (box.width - tab.width) / 2 + }, [pathname]) + + return ( +
+ ({ ...link, current: pathname === link.href }))} /> +
+ ) +} diff --git a/apps/web/src/app/admin/_components/ui.tsx b/apps/web/src/app/admin/_components/ui.tsx new file mode 100644 index 00000000..83fd662f --- /dev/null +++ b/apps/web/src/app/admin/_components/ui.tsx @@ -0,0 +1,137 @@ +import clsx from "clsx" +import type { ReactNode } from "react" + +/** + * Building blocks the admin pages share (DOS/Give#96 part C), on the #95 + * kit: a page frame, a section with its count, a card per queue item, a + * label/value grid and a table that scrolls sideways on phones. + */ + +/** A page: its title, an optional action on the right, the rules staff follow, then the sections. */ +export function AdminPage({ + title, + action, + intro, + children, +}: { + title: string + action?: ReactNode + intro?: ReactNode + children: ReactNode +}) { + return ( +
+
+
+

{title}

+ {action &&
{action}
} +
+ {intro &&
{intro}
} +
+ {children} +
+ ) +} + +/** A section with a heading, an optional count and lead, and its content. */ +export function AdminSection({ + id, + title, + count, + lead, + children, +}: { + id: string + title: string + /** A number, or a phrase such as "50 mới nhất / 120". */ + count?: number | string + lead?: ReactNode + children: ReactNode +}) { + return ( +
+
+

+ {title} + {count !== undefined && ( + {count} + )} +

+ {lead &&

{lead}

} +
+ {children} +
+ ) +} + +/** A list of queue items, one card each. */ +export function ItemList({ children }: { children: ReactNode }) { + return +} + +/** One queue item: facts on top, then the actions under a hairline. */ +export function ItemCard({ className, children }: { className?: string; children: ReactNode }) { + return
  • {children}
  • +} + +/** The actions of an item card, side by side from sm up. */ +export function ItemActions({ children }: { children: ReactNode }) { + return
    {children}
    +} + +/** A label/value grid; wrap each pair in . */ +export function Facts({ + columns = 3, + className, + children, +}: { + columns?: 2 | 3 | 4 + className?: string + children: ReactNode +}) { + return ( +
    + {children} +
    + ) +} + +export function Fact({ label, wide, children }: { label: ReactNode; wide?: boolean; children: ReactNode }) { + return ( +
    +
    {label}
    +
    {children}
    +
    + ) +} + +/** + * A table in a card that scrolls sideways when it does not fit (phones). + * The scroll box is focusable and named so keyboard users can scroll it. + */ +export function DataTable({ label, head, children }: { label: string; head: readonly string[]; children: ReactNode }) { + return ( +
    + + + + {head.map((h) => ( + + ))} + + + {children} +
    + {h} +
    +
    + ) +} diff --git a/apps/web/src/app/admin/account-swaps/page.tsx b/apps/web/src/app/admin/account-swaps/page.tsx index 94e82cb9..314b423e 100644 --- a/apps/web/src/app/admin/account-swaps/page.tsx +++ b/apps/web/src/app/admin/account-swaps/page.tsx @@ -1,9 +1,12 @@ import type { Metadata } from "next" -import Link from "next/link" import { notFound } from "next/navigation" import { AccountStatusBadge, StatusBadge, VerificationBadge } from "@/components/badges" import { ActionForm, SubmitButton } from "@/components/forms" +import { Badge, type BadgeTone } from "@/components/ui/Badge" +import { EmptyState } from "@/components/ui/EmptyState" +import { Field } from "@/components/ui/Field" +import { Notice } from "@/components/ui/Notice" import { approvalBlock, listDecidedSwaps, @@ -21,10 +24,18 @@ import { formatDate, formatDateTime } from "@/lib/format" import { FINANCE_ROLES } from "@/lib/receiving-accounts" import { dbAs } from "@/lib/supabase" +import { AdminPage, AdminSection, Fact, Facts, ItemActions, ItemCard, ItemList } from "../_components/ui" import { decideAccountSwap } from "./actions" export const metadata: Metadata = { title: "Đổi tài khoản nhận", robots: { index: false } } +const SWAP_TONES: Record = { + pending: "warning", + approved: "brand", + rejected: "danger", + cancelled: "neutral", +} + /** * Finance queue of account swaps (DOS/Give#55): organizers of give campaigns * that may hold money ask to replace an account that stopped being usable; @@ -44,13 +55,10 @@ export default async function AdminAccountSwapsPage() { ) return ( -
    -
    - - ← Xác minh tài khoản nhận - -

    Đổi tài khoản nhận

    -

    + Người tổ chức của chiến dịch từ thiện đang gây quỹ, đang tạm dừng, hoặc cần chỉnh sửa mà tài khoản đã nhận giao dịch, đề nghị thay tài khoản nhận không còn dùng được bằng một tài khoản khác đã xác minh, đứng tên họ. Chỉ duyệt khi có căn cứ rằng tài khoản cũ không dùng được nữa (thư hoặc thông báo của ngân hàng, kết quả kiểm @@ -58,14 +66,15 @@ export default async function AdminAccountSwapsPage() { tiền đã vào tài khoản cũ trước lúc đó vẫn thuộc chiến dịch. Trang chiến dịch và sao kê công khai ngày đổi. Mọi thao tác được ghi vào nhật ký kiểm toán.

    -
    - -
    -

    Chờ duyệt ({pending.length})

    + } + > + {pending.length === 0 ? ( -

    Không có yêu cầu nào chờ duyệt.

    + + Yêu cầu đổi tài khoản của người tổ chức sẽ hiện ở đây. + ) : ( -
      + {pending.map((swap) => ( ))} -
    + )} -
    + -
    -

    Đã xử lý gần đây ({decided.length})

    + {decided.length === 0 ? ( -

    Chưa có yêu cầu nào được xử lý.

    + ) : ( -
      + {decided.map((swap) => ( ))} -
    + )} -
    -
    + + ) } function AccountLine({ account }: { account: NonNullable["account"] }) { - if (!account) return
    —
    + if (!account) return
    —
    return ( -
    +
    {bankLabel(account.bank_bin)} · {account.account_number} ·{" "} {account.account_name} @@ -123,134 +131,120 @@ function SwapCard({ const block = oldTransactions === null ? null : approvalBlock(swap, oldTransactions) return ( -
  • +
    -
    -
    -
    Chiến dịch
    -
    - {campaign ? ( - <> - {campaign.title} - - - ) : ( - swap.campaign_id - )} -
    -
    -
    -
    Người tổ chức
    -
    - {organizer ? ( - <> - {organizer.display_name} (tên pháp lý: {organizer.legal_name}) - - - ) : ( - "—" - )} -
    -
    -
    -
    Tài khoản cũ
    - - {swap.old_binding && ( -

    - Gắn từ {formatDate(swap.old_binding.bound_at)} - {swap.old_binding.released_at ? `, ngừng lúc ${formatDateTime(swap.old_binding.released_at)}` : ""} - {oldTransactions !== null && ` · ${oldTransactions} giao dịch đã ghi nhận`} -

    - )} - {swap.old_binding?.account?.review_note && ( -

    Ghi chú tài chính: {swap.old_binding.account.review_note}

    - )} -
    -
    -
    Tài khoản mới
    - -
    -
    -
    Lý do người tổ chức đưa ra (gửi {formatDateTime(swap.created_at)})
    -
    {swap.reason}
    -
    - {swap.status !== "pending" && ( -
    -
    - {SWAP_STATUS_LABELS[swap.status]} lúc {formatDateTime(swap.decided_at)} -
    -
    - {swap.decision_note ?? "—"} - {swap.public_note && Công khai: {swap.public_note}} -
    -
    +

    + {campaign ? ( + <> + {campaign.title} + + + ) : ( + swap.campaign_id )} -

    - - {SWAP_STATUS_LABELS[swap.status]} - + + {SWAP_STATUS_LABELS[swap.status]}
    + + + {organizer ? ( + + {organizer.display_name} (tên pháp lý: {organizer.legal_name}) + + + ) : ( + "—" + )} + +
    +
    Tài khoản cũ
    + + {swap.old_binding && ( +
    + Gắn từ {formatDate(swap.old_binding.bound_at)} + {swap.old_binding.released_at ? `, ngừng lúc ${formatDateTime(swap.old_binding.released_at)}` : ""} + {oldTransactions !== null && ` · ${oldTransactions} giao dịch đã ghi nhận`} +
    + )} + {swap.old_binding?.account?.review_note && ( +
    Ghi chú tài chính: {swap.old_binding.account.review_note}
    + )} +
    +
    +
    Tài khoản mới
    + +
    + + {swap.reason} + + {swap.status !== "pending" && ( + + {swap.decision_note ?? "—"} + {swap.public_note && Công khai: {swap.public_note}} + + )} +
    {swap.status === "pending" && (own ? ( -

    Đây là chiến dịch của bạn; một nhân viên khác cần xử lý.

    + Đây là chiến dịch của bạn; một nhân viên khác cần xử lý. ) : ( -
    + {block ? ( -

    Chưa duyệt được: {block}

    + Chưa duyệt được: {block} ) : ( - -