Adds proposals + proposal_votes + policies + incidents + incident_updates
tables with full RLS, async voting flow, dismissal-appeal pathway,
versioned policies with provenance, and an anonymous-incident token mode.
Adds per-space configurable areas, financial visibility settings, member
self-profile + COI disclosure, and a mission statement that renders in
the app footer.
Reorganises lib/actions.ts into per-domain files (tasks, projects,
members, payments, contacts, knowledge-base, secrets, area-leads,
settings, imports, proposals, incidents, policies, areas) plus a barrel.
Adds lib/permissions.ts and lib/auth-helpers.ts so action bodies stop
duplicating the user-to-member-to-role check. Wires every action that has
a Zod schema; introduces a flexibleDateTime preprocessor so HTML date
pickers no longer hit "Invalid datetime".
Hardens RLS: drops NOT NULL on space_members.user_id for offline members,
adds a privilege-escalation trigger that blocks self-edits to role / tier
/ status / approved / has_card_access / space_id, closes four
cross-tenant holes in the governance kernel, and adds a partial unique
index so two admins cannot concurrently activate two drafts of the same
policy slug.
Ships every deployment artifact: Dockerfile, docker-compose,
.do/app.yaml, .env.example, /api/health, plus a managed-Supabase guide
(docs/DEPLOYMENT.md), an end-to-end self-hosted Droplet guide
(docs/DEPLOY_DO_SELFHOSTED.md), and a Supabase-CLI local-dev quickstart
(docs/LOCAL_DEV.md). Replaces middleware.ts with proxy.ts for Next.js 16.
Adds scripts/schema.sql as the canonical idempotent full deploy plus
fourteen numbered incremental migrations.
Adds scripts/seed.sql so a fresh local DB renders something instead of
empty pages: a demo space, five members across all roles, a v1 code of
conduct, three proposals in different states with seven votes already
cast, an open incident, a project, three tasks, a KB entry, and matching
activity-log rows.
Replaces v0-generated test scaffolding with 266 real tests across
__tests__/{actions,auth-helpers,components,governance,permissions,
security,utils,validations}.test.ts. Adds react-markdown + remark-gfm
for proposal / incident / policy body rendering. Deletes four dead v0
fix-* scripts, the unused components/app-shell.tsx, and the duplicate
app/auth/login/page.tsx.
Routes (31 total): /api/health, /api/paypal/sync, /auth/callback, /,
/login, /signup, /signup/confirm, /dashboard, /tasks, /projects,
/members, /payments, /financials, /contacts, /ops, /ops/[id], /ops/new,
/proposals, /proposals/[id], /proposals/new, /incidents, /incidents/[id],
/incidents/new, /policies, /policies/[slug], /policies/new, /profile,
/recruitment, /settings, /comms, /import.
Full pass-by-pass history is in docs/HANDOFF.md; the roadmap is in
docs/GOVERNANCE_FEATURES.md.
195 lines
7.6 KiB
TypeScript
195 lines
7.6 KiB
TypeScript
import { redirect } from 'next/navigation'
|
|
import Link from 'next/link'
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import type { FinancialVisibility, Payment, Space } from '@/lib/types'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
const DOLLAR = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
|
|
|
|
function isVisible(viz: FinancialVisibility, role: string | null): boolean {
|
|
if (viz === 'all_members_visible') return true
|
|
if (viz === 'board_visible') return role === 'admin' || role === 'board' || role === 'treasurer'
|
|
// treasurer_only
|
|
return role === 'admin' || role === 'board' || role === 'treasurer'
|
|
}
|
|
|
|
export default async function FinancialsPage() {
|
|
const supabase = await createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (!user) redirect('/login')
|
|
|
|
const { data: member } = await supabase
|
|
.from('space_members')
|
|
.select('space_id, role')
|
|
.eq('user_id', user.id)
|
|
.in('status', ['current', 'unverified', 'late'])
|
|
.maybeSingle()
|
|
if (!member?.space_id) redirect('/signup')
|
|
|
|
const { data: spaceRaw } = await supabase
|
|
.from('spaces')
|
|
.select('id, name, financial_visibility')
|
|
.eq('id', member.space_id)
|
|
.maybeSingle()
|
|
const space = spaceRaw as unknown as Pick<Space, 'id' | 'name'> & { financial_visibility: FinancialVisibility } | null
|
|
|
|
if (!space) {
|
|
return <PageShell title="Financials"><p className="font-sans text-sm">Space not found.</p></PageShell>
|
|
}
|
|
|
|
if (!isVisible(space.financial_visibility, member.role)) {
|
|
return (
|
|
<PageShell title="Financials">
|
|
<div className="bg-card rounded border border-border p-6 text-center">
|
|
<p className="font-sans text-sm text-foreground mb-2">Financial data is not visible to your role.</p>
|
|
<p className="font-mono text-[10px] text-muted-foreground">
|
|
Visibility is currently set to <strong>{space.financial_visibility.replace(/_/g, ' ')}</strong>.
|
|
Ask an admin to change it from Settings if your space wants broader transparency.
|
|
</p>
|
|
</div>
|
|
</PageShell>
|
|
)
|
|
}
|
|
|
|
const { data: paymentsRaw } = await supabase
|
|
.from('payments')
|
|
.select('*')
|
|
.eq('space_id', member.space_id)
|
|
.order('transaction_date', { ascending: false })
|
|
|
|
const payments = (paymentsRaw ?? []) as unknown as Payment[]
|
|
|
|
const now = new Date()
|
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
|
const startOfYear = new Date(now.getFullYear(), 0, 1)
|
|
|
|
let lifetime = 0
|
|
let thisYear = 0
|
|
let thisMonth = 0
|
|
let linked = 0
|
|
let unlinked = 0
|
|
const byPlatform: Record<string, number> = {}
|
|
const byMonth: Record<string, number> = {}
|
|
|
|
for (const p of payments) {
|
|
const amount = Number(p.amount ?? 0)
|
|
lifetime += amount
|
|
const tx = p.transaction_date ? new Date(p.transaction_date) : null
|
|
if (tx) {
|
|
if (tx >= startOfYear) thisYear += amount
|
|
if (tx >= startOfMonth) thisMonth += amount
|
|
const key = `${tx.getFullYear()}-${String(tx.getMonth() + 1).padStart(2, '0')}`
|
|
byMonth[key] = (byMonth[key] ?? 0) + amount
|
|
}
|
|
if (p.link_status === 'linked') linked++
|
|
else unlinked++
|
|
const platform = String(p.platform ?? 'unknown')
|
|
byPlatform[platform] = (byPlatform[platform] ?? 0) + amount
|
|
}
|
|
|
|
const monthlyRows = Object.entries(byMonth)
|
|
.sort(([a], [b]) => b.localeCompare(a))
|
|
.slice(0, 12)
|
|
|
|
const platformRows = Object.entries(byPlatform).sort(([, a], [, b]) => b - a)
|
|
const maxPlatform = Math.max(1, ...platformRows.map(([, v]) => v))
|
|
|
|
return (
|
|
<PageShell title={`Financials — ${space.name}`}>
|
|
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2">
|
|
Visibility: {space.financial_visibility.replace(/_/g, ' ')}
|
|
{member.role === 'admin' && (
|
|
<>
|
|
{' · '}
|
|
<Link href="/settings" className="text-primary hover:underline normal-case tracking-normal">
|
|
change in Settings
|
|
</Link>
|
|
</>
|
|
)}
|
|
</p>
|
|
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4">
|
|
<StatCard label="This Month" value={DOLLAR.format(thisMonth)} sub="received" />
|
|
<StatCard label="This Year" value={DOLLAR.format(thisYear)} sub={`${now.getFullYear()}`} />
|
|
<StatCard label="Lifetime" value={DOLLAR.format(lifetime)} sub="all payments" />
|
|
<StatCard
|
|
label="Reconciliation"
|
|
value={`${linked}/${linked + unlinked}`}
|
|
sub={`${unlinked} unlinked`}
|
|
warn={unlinked > 0}
|
|
/>
|
|
</div>
|
|
|
|
<section className="bg-card rounded border border-border p-5">
|
|
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
|
By platform
|
|
</p>
|
|
{platformRows.length === 0 ? (
|
|
<p className="font-sans text-sm text-muted-foreground">No payments recorded yet.</p>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{platformRows.map(([platform, total]) => {
|
|
const pct = Math.round((total / maxPlatform) * 100)
|
|
return (
|
|
<li key={platform}>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="font-sans text-sm capitalize text-foreground">{platform}</span>
|
|
<span className="font-mono text-xs text-muted-foreground">{DOLLAR.format(total)}</span>
|
|
</div>
|
|
<div className="h-1.5 bg-border rounded-full overflow-hidden">
|
|
<div className="h-full bg-primary rounded-full" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
<section className="bg-card rounded border border-border p-5">
|
|
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
|
Last 12 months
|
|
</p>
|
|
{monthlyRows.length === 0 ? (
|
|
<p className="font-sans text-sm text-muted-foreground">No monthly breakdown yet.</p>
|
|
) : (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
|
{monthlyRows.map(([month, total]) => {
|
|
const [y, m] = month.split('-')
|
|
const label = new Date(Number(y), Number(m) - 1, 1).toLocaleDateString('en-US', { year: 'numeric', month: 'short' })
|
|
return (
|
|
<div key={month} className="flex items-center justify-between bg-background rounded border border-border px-3 py-2">
|
|
<span className="font-sans text-sm text-foreground">{label}</span>
|
|
<span className="font-mono text-xs text-muted-foreground">{DOLLAR.format(total)}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</PageShell>
|
|
)
|
|
}
|
|
|
|
function PageShell({ title, children }: { title: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<div className="bg-sidebar px-4 md:px-6 py-3">
|
|
<h1 className="text-white font-sans text-lg font-semibold">{title}</h1>
|
|
</div>
|
|
<div className="p-4 md:p-6 space-y-6 max-w-4xl">{children}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StatCard({ label, value, sub, warn }: { label: string; value: string; sub: string; warn?: boolean }) {
|
|
return (
|
|
<div className="bg-card rounded border border-border p-4 md:p-5">
|
|
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2">{label}</p>
|
|
<p className={`text-2xl font-sans font-bold ${warn ? 'text-orange-500' : 'text-primary'}`}>{value}</p>
|
|
<p className="font-sans text-xs text-muted-foreground mt-1">{sub}</p>
|
|
</div>
|
|
)
|
|
}
|