diff --git a/.env.example b/.env.example index 5e1e26b..bef4fef 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 # Optional comma-separated hostnames for proxied Next.js dev server origins. NEXT_ALLOWED_DEV_ORIGINS= +# Enables local Playwright-only mock sessions. Never set in production. +E2E_AUTH_ENABLED=false # Server-only configuration DATABASE_URL= diff --git a/.gitignore b/.gitignore index 7b8da95..d3d2568 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ # testing /coverage +/playwright-report +/test-results # next.js /.next/ diff --git a/README.md b/README.md index 9c20d1b..e5ca7e2 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,43 @@ Keep machine-specific proxy hostnames and URLs out of committed source. - `pnpm build`: create a production build. - `pnpm start`: run the production build. - `pnpm lint`: run ESLint. +- `pnpm test:e2e`: run Playwright responsive smoke tests. +- `pnpm test:e2e:install`: install the Playwright Chromium browser. - `pnpm db:generate`: generate Drizzle SQL migrations from the schema. - `pnpm db:migrate`: apply Drizzle migrations to `DATABASE_URL`. - `pnpm db:reset:local`: drop and recreate the local `public` schema, then run migrations. - `pnpm db:studio`: open Drizzle Studio for local database inspection. +## End-to-End QA + +The Playwright suite checks public and gated dashboard routes across mobile, +tablet, and desktop viewports. It uses the local database state that is already +present. Detail-page checks skip with a clear message when the local database +does not contain a matching quarter or report. + +On a fresh Ubuntu server, install browser system dependencies once: + +```bash +pnpm exec playwright install-deps chromium +``` + +Then install the Chromium browser bundle: + +```bash +pnpm test:e2e:install +``` + +Run the suite: + +```bash +pnpm test:e2e +``` + +The tests start the Next.js dev server with `E2E_AUTH_ENABLED=true` and use a +local-only mock session endpoint for member, cleric, and admin route coverage. +That endpoint returns 404 unless `E2E_AUTH_ENABLED=true` and the app is not +running in production. + ## Stack - Next.js App Router. diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..ac9d10f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,8 @@ const eslintConfig = defineConfig([ ".next/**", "out/**", "build/**", + "playwright-report/**", + "test-results/**", "next-env.d.ts", ]), ]); diff --git a/package.json b/package.json index 025f3b5..fd5490f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "build": "next build", "start": "next start", "lint": "eslint .", + "test:e2e": "playwright test", + "test:e2e:install": "playwright install chromium", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:reset:local": "node scripts/reset-local-db.mjs", @@ -43,6 +45,7 @@ "wagmi": "^3.6.16" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/pg": "^8.18.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..03a0396 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,56 @@ +import { defineConfig, devices } from "@playwright/test"; + +const port = Number(process.env.PLAYWRIGHT_PORT ?? 3131); +const host = "127.0.0.1"; +const baseURL = `http://${host}:${port}`; + +export default defineConfig({ + expect: { + timeout: 10_000, + }, + fullyParallel: false, + outputDir: "test-results/e2e", + reporter: [["list"], ["html", { open: "never" }]], + testDir: "./tests/e2e", + timeout: 45_000, + use: { + baseURL, + trace: "retain-on-failure", + }, + webServer: { + command: `E2E_AUTH_ENABLED=true COREPACK_HOME=/tmp/corepack corepack pnpm exec next dev -H ${host} -p ${port}`, + reuseExistingServer: false, + timeout: 120_000, + url: baseURL, + }, + projects: [ + { + name: "mobile", + use: { + ...devices["Pixel 5"], + viewport: { height: 844, width: 390 }, + }, + }, + { + name: "small-mobile", + use: { + ...devices["Pixel 5"], + viewport: { height: 740, width: 360 }, + }, + }, + { + name: "tablet", + use: { + ...devices["iPad (gen 7)"], + browserName: "chromium", + viewport: { height: 1024, width: 768 }, + }, + }, + { + name: "desktop", + use: { + viewport: { height: 900, width: 1440 }, + }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe40f26..7401d8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 5.100.14(react@19.2.4) '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 2.0.1(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) '@x402/core': specifier: ^2.17.0 version: 2.17.0 @@ -28,7 +28,7 @@ importers: version: 2.17.0(typescript@5.9.3) '@x402/next': specifier: ^2.17.0 - version: 2.17.0(ethers@6.16.0)(next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + version: 2.17.0(ethers@6.16.0)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -49,7 +49,7 @@ importers: version: 1.17.0(react@19.2.4) next: specifier: 16.2.6 - version: 16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) pg: specifier: ^8.18.0 version: 8.18.0 @@ -78,6 +78,9 @@ importers: specifier: ^3.6.16 version: 3.6.16(@tanstack/query-core@5.100.14)(@tanstack/react-query@5.100.14(react@19.2.4))(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(viem@2.51.3(typescript@5.9.3)(zod@4.4.3)) devDependencies: + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@tailwindcss/postcss': specifier: ^4 version: 4.3.0 @@ -1017,6 +1020,11 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -2125,6 +2133,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2808,6 +2821,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3995,6 +4018,10 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@rtsao/scc@1.1.0': {} '@scure/base@1.2.6': {} @@ -4316,9 +4343,9 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vercel/analytics@2.0.1(next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + '@vercel/analytics@2.0.1(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': optionalDependencies: - next: 16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 '@wagmi/connectors@8.0.15(@wagmi/core@3.5.0(@tanstack/query-core@5.100.14)(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.51.3(typescript@5.9.3)(zod@4.4.3)))(typescript@5.9.3)(viem@2.51.3(typescript@5.9.3)(zod@4.4.3))': @@ -4374,11 +4401,11 @@ snapshots: - typescript - utf-8-validate - '@x402/next@2.17.0(ethers@6.16.0)(next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': + '@x402/next@2.17.0(ethers@6.16.0)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': dependencies: '@x402/core': 2.17.0 '@x402/extensions': 2.17.0(ethers@6.16.0)(typescript@5.9.3) - next: 16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) transitivePeerDependencies: - bufferutil - ethers @@ -5244,6 +5271,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5725,7 +5755,7 @@ snapshots: natural-compare@1.4.0: {} - next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.6 '@swc/helpers': 0.5.15 @@ -5744,6 +5774,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.6 '@next/swc-win32-arm64-msvc': 16.2.6 '@next/swc-win32-x64-msvc': 16.2.6 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -5914,6 +5945,14 @@ snapshots: picomatch@4.0.4: {} + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss@8.4.31: diff --git a/src/app/admin/providers/page.tsx b/src/app/admin/providers/page.tsx index 77d18b9..927996f 100644 --- a/src/app/admin/providers/page.tsx +++ b/src/app/admin/providers/page.tsx @@ -479,8 +479,8 @@ function ProviderRankingTable({ {providers.length > 0 ? ( -
- +
+
@@ -501,7 +501,8 @@ function ProviderRankingTable({ key={provider.id} className="transition-colors hover:bg-muted/50" > - - - - - -
Provider + + Provider: + + Spend: + + Entries: + + Website: + + Addresses: + + Status: @@ -179,7 +179,7 @@ export function ClassificationLinkedFields({ name={counterpartyDisabled ? undefined : "counterpartyEntityId"} defaultValue={defaultCounterpartyEntityId ?? ""} disabled={counterpartyDisabled} - className="h-9 rounded-md border border-input bg-background px-3 text-sm disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground" + className="h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 text-sm disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground" > diff --git a/src/app/admin/quarters/[id]/transactions/page.tsx b/src/app/admin/quarters/[id]/transactions/page.tsx index 522a1e9..b6059a1 100644 --- a/src/app/admin/quarters/[id]/transactions/page.tsx +++ b/src/app/admin/quarters/[id]/transactions/page.tsx @@ -33,6 +33,7 @@ import { SyncTransactionsForm } from "@/app/admin/quarters/[id]/transactions/syn import { TransactionReviewToast } from "@/app/admin/quarters/[id]/transactions/transaction-review-toast"; import { UsdAmountField } from "@/app/admin/quarters/[id]/transactions/usd-amount-field"; import { getAuthSession, serializeSession } from "@/lib/auth/session"; +import { cn } from "@/lib/utils"; import { getQuarterClassificationSummary, listQuarters, @@ -91,6 +92,49 @@ function formatHash(hash: string) { return `${hash.slice(0, 10)}...${hash.slice(-8)}`; } +function TransactionExplorerLink({ + hash, + url, + variant = "button", +}: { + hash: string; + url: string | null; + variant?: "button" | "inline"; +}) { + const isInline = variant === "inline"; + + if (!url) { + return ( + + {formatHash(hash)} + + ); + } + + return ( + + {formatHash(hash)} + + ); +} + function getTransactionExplorerUrl({ chainId, txHash, @@ -248,26 +292,26 @@ function QuarterBalancesPanel({
-
-
+
+

Total

{balances.length} account{balances.length === 1 ? "" : "s"}

-
+

Opening

{formatCurrencyNumber(totalBalance.openingUsd)}

-
+

Closing

{formatCurrencyNumber(totalBalance.closingUsd)}

-
+

Net Change

{formatCurrencyNumber(totalBalance.netChangeUsd)} @@ -286,7 +330,7 @@ function QuarterBalancesPanel({ key={`${balance.chainId}:${balance.accountAddress}`} className="rounded-md border border-border bg-background" > -

+

{balance.accountName}

-
+

Opening

{formatCurrencyNumber(balance.openingUsd)}

-
+

Closing

{formatCurrencyNumber(balance.closingUsd)}

-
+

Net Change

@@ -315,8 +359,8 @@ function QuarterBalancesPanel({

-
- +
+
@@ -347,19 +391,24 @@ function QuarterBalancesPanel({ return ( - - + - - -
Asset
{symbol} + + Asset: {symbol} + Opening Balance: {opening ? formatTokenAmount(opening.balance) : "-"} + + Closing Balance: {closing ? formatTokenAmount(closing.balance) : "-"} + + Opening USD: {opening ? formatCurrencyNumber(Number(opening.usdValue)) : "-"} + + Closing USD: {closing ? formatCurrencyNumber(Number(closing.usdValue)) : "-"} @@ -472,8 +521,8 @@ function BalanceValidationPanel({ ) : null} {topVariances.length > 0 ? ( -
- +
+
@@ -490,25 +539,32 @@ function BalanceValidationPanel({ - - - - - + + - - @@ -1008,10 +1064,10 @@ function TransferCard({ return (
-
+
{position} / {total} @@ -1089,23 +1145,11 @@ function TransferCard({ {isSwap ? : null}
-
- {transactionExplorerUrl ? ( - - {formatHash(transfer.txHash)} - - ) : ( - - {formatHash(transfer.txHash)} - - )} +
+
@@ -1134,7 +1178,7 @@ function TransferCard({ return (
@@ -1198,20 +1242,11 @@ function TransferCard({
Tx Hash
- {transactionExplorerUrl ? ( - - {formatHash(transfer.txHash)} - - ) : ( - {formatHash(transfer.txHash)} - )} +
@@ -1287,12 +1322,12 @@ function ManualLedgerEntryCard({ return (
-
+
{position} / {total} @@ -1359,18 +1394,12 @@ function ManualLedgerEntryCard({ {entry.notes ? Notes: {entry.notes} : null}
-
+
{transactionExplorerUrl && explorerTxHash ? ( - - {formatHash(explorerTxHash)} - + ) : null} {quarter.status === "draft" && entry.source === "manual" && @@ -1552,9 +1581,9 @@ export default async function QuarterTransactionsPage({ syncStatus={toastSyncStatus} /> -
-
-
+
+
+
Quarters -
-
-
+
+
+
-
-

+

+

{formatDate(quarter.startsOn)} - {formatDate(quarter.endsOn)}

-

+

{quarter.label} Transaction Review

diff --git a/src/app/admin/treasury-accounts/page.tsx b/src/app/admin/treasury-accounts/page.tsx index 9f0e25f..d91bd9c 100644 --- a/src/app/admin/treasury-accounts/page.tsx +++ b/src/app/admin/treasury-accounts/page.tsx @@ -454,8 +454,8 @@ function AccountRankingTable({
{accounts.length > 0 ? ( -
-
Account
+ + Account:

{variance.accountName}

+ + Asset: {variance.assetSymbol} {variance.opening}{variance.movement} + + Opening: {variance.opening} + Movement: {variance.movement} + Expected: {variance.expectedClosing} + + Actual: {variance.actualClosing} + + Difference: {variance.difference}
+
+
@@ -476,7 +476,8 @@ function AccountRankingTable({ key={account.id} className="transition-colors hover:bg-muted/50" > - - - - - -
Account + + Account: + + Balance: + + Type: + + Chain: + + Address: + + Status: null)) as + | E2ESessionRequest + | null; + const role = parseRole(body?.role); + + if (!role) { + return NextResponse.json({ error: "Invalid role" }, { status: 400 }); + } + + const session = await getAuthSession(); + + session.address = E2E_WALLET_ADDRESS; + session.authenticatedAt = new Date().toISOString(); + session.chainId = 100; + session.permissions = getPermissions(role); + session.viewMode = role; + delete session.nonce; + await session.save(); + + return NextResponse.json(serializeSession(session)); +} + +export async function DELETE(request: Request) { + if (!isE2EAuthEnabled(request)) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const session = await getAuthSession(); + session.destroy(); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/globals.css b/src/app/globals.css index 85593d6..8f5971c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -142,10 +142,18 @@ @layer base { * { @apply border-border outline-ring/50; + + box-sizing: border-box; + } + *::before, + *::after { + box-sizing: border-box; } body { @apply bg-background text-foreground; + font-family: var(--font-ui); + margin: 0; } html { @apply font-sans; @@ -175,6 +183,26 @@ } } + @keyframes drawer-backdrop-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes drawer-panel-in { + from { + opacity: 0.92; + transform: translateX(1rem); + } + to { + opacity: 1; + transform: translateX(0); + } + } + .toast-enter { animation: toast-in 180ms ease-out both; } @@ -183,8 +211,16 @@ animation: toast-out 180ms ease-in both; } + .drawer-backdrop-enter { + animation: drawer-backdrop-in 160ms ease-out both; + } + + .drawer-panel-enter { + animation: drawer-panel-in 180ms ease-out both; + } + .container-custom { - @apply mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8; + @apply mx-auto box-border w-full max-w-7xl px-3 sm:px-6 lg:px-8; } .type-label { @@ -194,4 +230,123 @@ .type-label-sm { @apply text-[0.7rem] font-semibold tracking-normal; } + + .mobile-table-scroll { + @apply -mx-3 overflow-x-auto px-3 sm:mx-0 sm:px-0; + + scrollbar-width: thin; + } + + .overflow-wrap-anywhere { + overflow-wrap: anywhere; + } + + .mobile-card-table { + @apply w-full text-left text-sm; + } + + .mobile-card-table thead { + @apply sr-only md:not-sr-only md:table-header-group; + } + + .mobile-card-table tbody { + @apply grid gap-3 md:table-row-group; + } + + .mobile-card-table tr { + @apply grid gap-2 rounded-md border border-border bg-background p-3 md:table-row md:rounded-none md:border-0 md:bg-transparent md:p-0; + } + + .mobile-card-table tbody tr { + @apply md:border-b md:border-border; + } + + .mobile-card-table tbody tr:last-child { + @apply md:border-b-0; + } + + .mobile-card-table td, + .mobile-card-table th { + @apply md:px-4 md:py-3; + } + + .mobile-card-table td { + @apply grid min-w-0 grid-cols-[7.5rem_minmax(0,1fr)] gap-3 py-0 text-right md:table-cell md:text-left; + + overflow-wrap: anywhere; + } + + .mobile-card-table td::before { + content: attr(data-label) / ""; + @apply text-left text-xs font-semibold text-muted-foreground uppercase md:hidden; + } + + .mobile-card-table td[data-align="right"] { + @apply md:text-right; + } + + .mobile-card-table td[data-full="true"] { + @apply grid-cols-1 text-left; + } + + .mobile-card-table td[data-full="true"]::before { + @apply sr-only; + } + + .mobile-card-table-lg { + @apply w-full text-left text-sm; + } + + .mobile-card-table-lg thead { + @apply sr-only lg:not-sr-only lg:table-header-group; + } + + .mobile-card-table-lg tbody { + @apply grid gap-3 lg:table-row-group; + } + + .mobile-card-table-lg tr { + @apply grid gap-2 rounded-md border border-border bg-background p-3 lg:table-row lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0; + } + + .mobile-card-table-lg tbody tr { + @apply lg:border-b lg:border-border; + } + + .mobile-card-table-lg tbody tr:last-child { + @apply lg:border-b-0; + } + + .mobile-card-table-lg td, + .mobile-card-table-lg th { + @apply lg:px-4 lg:py-3; + } + + .mobile-card-table-lg td { + @apply grid min-w-0 grid-cols-[7.5rem_minmax(0,1fr)] gap-3 py-0 text-right lg:table-cell lg:text-left; + + overflow-wrap: anywhere; + } + + .transaction-review-mobile-shell { + width: calc(100% - 0.25rem); + max-width: calc(100% - 0.25rem); + } + + .mobile-card-table-lg td::before { + content: attr(data-label) / ""; + @apply text-left text-xs font-semibold text-muted-foreground uppercase lg:hidden; + } + + .mobile-card-table-lg td[data-align="right"] { + @apply lg:text-right; + } + + .mobile-card-table-lg td[data-full="true"] { + @apply grid-cols-1 text-left; + } + + .mobile-card-table-lg td[data-full="true"]::before { + @apply sr-only; + } } diff --git a/src/app/membership/page.tsx b/src/app/membership/page.tsx index 7f183c2..8da4100 100644 --- a/src/app/membership/page.tsx +++ b/src/app/membership/page.tsx @@ -255,8 +255,8 @@ function ClericAccessSection({ {clericRoles.length > 0 ? ( -
- +
+
@@ -271,13 +271,15 @@ function ClericAccessSection({ {clericRoles.map((role) => ( - - - - -
Wallet
+ + Wallet: + + Status: + + Granted: {formatTimestamp(role.createdAt.toISOString())} + + Revoked: {role.revokedAt ? formatTimestamp(role.revokedAt.toISOString()) : "-"} + + Actions: {role.revokedAt ? null : (
- +
+
@@ -356,23 +361,25 @@ function MembershipActivityTable({ rows }: { rows: MembershipActivityRow[] }) { {rows.map((row) => ( - - - - - - - -
Activity
+ + Activity: {row.proposalTitle ? ( -

+

{row.proposalTitle}

) : null}
+ + Member: {row.recipientAddress && row.recipientAddress.toLowerCase() !== row.memberAddress.toLowerCase() ? ( -
+
) : null}
+ + Executed: {formatTimestamp(row.executedAt)} + + Amount: {formatAmount({ amount: row.assetAmount, symbol: row.assetSymbol, })} + + USD: {formatCurrency(row.usdAmount)} + + Shares:

{formatShares(row.shares)}

{row.loot ? (

@@ -400,7 +411,8 @@ function MembershipActivityTable({ rows }: { rows: MembershipActivityRow[] }) {

) : null}
+ + Quarter:

{row.quarterLabel ?? "-"}

{row.quarterStatus ? (

@@ -408,8 +420,9 @@ function MembershipActivityTable({ rows }: { rows: MembershipActivityRow[] }) {

) : null}
-
+
+ Links: +
{row.daohausUrl ? ( DAOhaus ) : null} diff --git a/src/app/page.tsx b/src/app/page.tsx index e7c5ea1..7d56293 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -38,11 +38,10 @@ function PublicHome({ session }: { session: SessionState }) { RaidGuild

Accounting Dashboard diff --git a/src/app/proposals/page.tsx b/src/app/proposals/page.tsx index bea10ba..118b18c 100644 --- a/src/app/proposals/page.tsx +++ b/src/app/proposals/page.tsx @@ -81,8 +81,8 @@ function ProposalActivityTable({ rows }: { rows: ProposalActivityRow[] }) { } return ( -

- +
+
@@ -97,13 +97,15 @@ function ProposalActivityTable({ rows }: { rows: ProposalActivityRow[] }) { {rows.map((row) => ( - - - - - - - ); } return ( -
Proposal
-

{row.title}

+
+ Proposal: +

{row.title}

{row.proposalNumber ?? row.proposalId}

+ + Quarter:

{row.quarterLabel ?? "-"}

{row.quarterStatus ? (

@@ -111,16 +113,20 @@ function ProposalActivityTable({ rows }: { rows: ProposalActivityRow[] }) {

) : null}
+ + Executed: {formatTimestamp(row.executedAt)} + + Amount: {formatAmount(row.assetAmount)} {row.assetSymbol} + + USD: {formatCurrency(row.usdAmount)} + + Classification:

{row.category ?? "Unclassified"}

{row.counterpartyName ? (

@@ -128,8 +134,9 @@ function ProposalActivityTable({ rows }: { rows: ProposalActivityRow[] }) {

) : null}
-
+
+ Links: +
DAOhaus {row.explorerUrl ? ( diff --git a/src/app/raids/page.tsx b/src/app/raids/page.tsx index 0416920..d41e260 100644 --- a/src/app/raids/page.tsx +++ b/src/app/raids/page.tsx @@ -433,22 +433,34 @@ function AccountingSectionHeader({ function TableLinkCell({ children, className = "", + full = false, href, + label, + numeric = false, }: { children: ReactNode; className?: string; + full?: boolean; href?: string | null; + label: string; + numeric?: boolean; }) { + const dataProps = { + "data-align": numeric ? "right" : undefined, + "data-full": full ? "true" : undefined, + "data-label": label, + }; + if (!href) { return ( -
+
{children}
+ {clients.length > 0 ? ( -
- +
+
@@ -504,28 +516,30 @@ function TopClientsTable({ key={client.clientId} className="transition-colors hover:bg-muted/50" > - + {client.clientName} {formatAccountingCurrency(client.revenueCents)} - + {client.raidCount} - + {formatAccountingCurrency(client.expectedSpoilsCents)} - + {formatAccountingCurrency(client.spoilsReceivedCents)} - + {formatAccountingCurrency(client.expectedTeamPoolCents)} - + {formatAccountingCurrency( client.subcontractorPayoutCents, )} @@ -559,8 +573,8 @@ function RaidAccountingTable({ title="Raid Accounting" /> {summaries.length > 0 ? ( -
-
Client
+
+
@@ -595,10 +609,10 @@ function RaidAccountingTable({ key={summary.raidId} className="transition-colors hover:bg-muted/50" > - + {summary.clientName} - +
{summary.raidName} {summary.isShipped ? ( @@ -610,34 +624,36 @@ function RaidAccountingTable({ {formatAccountingCurrency(summary.revenueCents)} - + {formatAccountingCurrency(summary.expectedSpoilsCents)} - + {formatAccountingCurrency(summary.spoilsReceivedCents)} - + {formatAccountingCurrency(summary.remainingSpoilsCents)} - + - + {formatAccountingCurrency(summary.expectedTeamPoolCents)} - + {formatAccountingCurrency( summary.subcontractorPayoutCents, )} - + {formatAccountingCurrency(summary.remainingPoolCents)} - +
@@ -669,8 +685,8 @@ function SubcontractorAccountingTable({ title="Subcontractor Accounting" /> {summaries.length > 0 ? ( -
-
Client
+
+
@@ -690,21 +706,23 @@ function SubcontractorAccountingTable({ key={summary.subcontractorId} className="transition-colors hover:bg-muted/50" > - + {summary.subcontractorName} {formatAccountingCurrency( summary.subcontractorPayoutCents, )} - + {summary.raidCount} - + {summary.isArchived ? ( Archived diff --git a/src/app/reports/page.tsx b/src/app/reports/page.tsx index 620a103..fc0f950 100644 --- a/src/app/reports/page.tsx +++ b/src/app/reports/page.tsx @@ -117,17 +117,17 @@ export default async function ReportsPage() { : "Ready for publication preview"}

-
+
View Report Export XLSX diff --git a/src/app/reports/quarters/[id]/page.tsx b/src/app/reports/quarters/[id]/page.tsx index a30fbc4..a40a187 100644 --- a/src/app/reports/quarters/[id]/page.tsx +++ b/src/app/reports/quarters/[id]/page.tsx @@ -123,8 +123,8 @@ function RankedTable({
{rows.length > 0 ? ( -
-
Subcontractor
+
+
@@ -134,21 +134,27 @@ function RankedTable({ - - + - {rows.map((row) => ( - - - + @@ -191,8 +197,8 @@ function BalanceTable({ {balances.length > 0 ? ( -
-
Name
Total + + Name: Total + Entries: {totals.entries} + + Total: {formatCurrency(totals.totalUsd)}
+ + Name: {row.label} {row.entries} + + Entries: {row.entries} + Total: {formatCurrency(row.totalUsd)}
+
+
@@ -205,33 +211,41 @@ function BalanceTable({ - - + - - {balances.map((balance) => ( - - - - diff --git a/src/app/rips/page.tsx b/src/app/rips/page.tsx index 30522ba..632396e 100644 --- a/src/app/rips/page.tsx +++ b/src/app/rips/page.tsx @@ -239,8 +239,8 @@ function RipTable({ rips }: { rips: RipView[] }) { {rips.length} RIP{rips.length === 1 ? "" : "s"} -
-
Account
Total + + Account: Total + Opening: {formatCurrency(total.openingUsd)} + + Closing: {formatCurrency(total.closingUsd)} + + Net Change: {formatCurrency(total.netChangeUsd)}
+ + Account:

{balance.accountName}

+ + Opening: {formatCurrency(balance.openingUsd)} + + Closing: {formatCurrency(balance.closingUsd)} + + Net Change: {formatCurrency(balance.netChangeUsd)}
+
+
@@ -258,47 +258,52 @@ function RipTable({ rips }: { rips: RipView[] }) { return ( - - - - -
RIP
+ + RIP: {rip.title} + + Created: {formatTimestamp(rip.createdAt)} + + Linked Spend: {formatCurrency(rip.totalUsd)} + + Entries: {rip.entryCount} + + Edit: