Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions apps/web/src/app/(app)/orgs/[slug]/documents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { storageConfigured } from "@/lib/s3"
import { aiConfigured } from "@/lib/ai"
import { Card, CardHeader } from "@/components/ui/Card"
import { OrgTabs } from "@/components/OrgTabs"
import { ExportButton } from "@/components/export/ExportButton"
import { DocumentRow } from "@/components/documents/DocumentRow"
import { ArchiveRestore } from "@/components/ui/icons"
import { ReportingForm } from "@/components/forms/ReportingForm"
Expand Down Expand Up @@ -134,11 +135,14 @@ export default async function DocumentsPage({

return (
<div className="w-full">
<div className="mb-4">
<h1 className="text-text-1">{org.name}</h1>
<p className="text-sm text-text-2 mt-1">
Club documents. Contracts, templates and records in one durable place.
</p>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-text-1">{org.name}</h1>
<p className="text-sm text-text-2 mt-1">
Club documents. Contracts, templates and records in one durable place.
</p>
</div>
<ExportButton href={`/api/export/orgs/${slug}/documents`} label="the document index" />
</div>
<OrgTabs slug={slug} />

Expand Down
22 changes: 5 additions & 17 deletions apps/web/src/app/(app)/orgs/[slug]/finance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
seatCarriesFinanceAuthority,
} from "@/lib/rbac"
import { withTenantScope } from "@/lib/tenant-scope"
import { Download } from "@/components/ui/icons"
import { OrgTabs } from "@/components/OrgTabs"
import { ExportButton } from "@/components/export/ExportButton"
import { FinanceDashboard } from "@/components/finance/FinanceDashboard"
import { ReimbursementForm } from "@/components/finance/ReimbursementForm"
import { type LedgerEntryRow } from "@/components/finance/LedgerDrawer"
Expand Down Expand Up @@ -183,22 +183,10 @@ export default async function FinancePage({
Finance. Actual against budget for {CURRENT_YEAR}, with editable forecasting.
</p>
</div>
{/* A plain <a>, not a fetch-and-blob. The route answers with
Content-Disposition: attachment, so the browser saves the file
and the page does not move — and a download that is an ordinary
link keeps working with the middle button, a right-click, and a
screen reader that announces it as one. */}
<a
href={`/api/export/orgs/${slug}/finance`}
// Says this is a file rather than a page. The template literal
// hides it from `no-html-link-for-pages`, so the lint does not
// demand it here — but the attribute is what makes the element
// honest, and a `<Link>` would be wrong for a download either way.
download
className="inline-flex shrink-0 items-center gap-1.5 rounded border border-border px-3 py-1.5 text-xs font-medium text-text-2 hover:bg-base hover:text-text-1"
>
<Download size={13} /> Export PDF
</a>
{/* The same control every other module uses. This page had the
original hand-written anchor; the reasoning that was in this
comment now lives in the component, where all six share it. */}
<ExportButton href={`/api/export/orgs/${slug}/finance`} label="the budget" />
</div>
<OrgTabs slug={slug} />

Expand Down
15 changes: 10 additions & 5 deletions apps/web/src/app/(app)/orgs/[slug]/impact/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { Card, CardHeader } from "@/components/ui/Card"
import { StatGrid, StatTile } from "@/components/ui/Bento"
import { OrgTabs } from "@/components/OrgTabs"
import { ExportButton } from "@/components/export/ExportButton"
import { formatCents, PLAN_LINE_WHERE } from "@/lib/finance"
import { currentTerm } from "@/lib/tenant/term"

Expand Down Expand Up @@ -118,11 +119,15 @@ export default async function ImpactPage({

return (
<div className="w-full">
<div className="mb-6">
<h1 className="text-text-1">{org.name}</h1>
<p className="mt-1 text-lead text-text-2">
Impact. A shareable summary of what this club has achieved, built from its own record.
</p>
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-text-1">{org.name}</h1>
<p className="mt-1 text-lead text-text-2">
Impact. A shareable summary of what this club has achieved, built from its own
record.
</p>
</div>
<ExportButton href={`/api/export/orgs/${slug}/impact`} label="the impact summary" />
</div>
<OrgTabs slug={slug} />

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/(app)/orgs/[slug]/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Card, CardHeader } from "@/components/ui/Card"
import { AssignmentBadge, Badge } from "@/components/ui/Badge"
import { Avatar } from "@/components/ui/Avatar"
import { OrgTabs } from "@/components/OrgTabs"
import { ExportButton } from "@/components/export/ExportButton"
import { EmailLink } from "@/components/EmailLink"
import { ClubImageEditor } from "@/components/ClubImageEditor"
import { ConfirmSubmit } from "@/components/ui/ConfirmDialog"
Expand Down Expand Up @@ -123,6 +124,7 @@ export default async function MembersPage({
canUpload={storageConfigured()}
/>
)}
<ExportButton href={`/api/export/orgs/${slug}/roster`} label="the roster" />
</div>
<OrgTabs slug={slug} />

Expand Down
20 changes: 15 additions & 5 deletions apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { MemoryProvenance, type MovementView } from "@/components/memory/MemoryP
import { Card, CardHeader } from "@/components/ui/Card"
import { Badge } from "@/components/ui/Badge"
import { OrgTabs } from "@/components/OrgTabs"
import { ExportButton } from "@/components/export/ExportButton"
import { AddMemoryCardForm } from "@/components/memory/AddMemoryCardForm"
import { aiConfigured } from "@/lib/ai"
import { createMemoryCard } from "./actions"
Expand Down Expand Up @@ -323,11 +324,20 @@ export default async function MemoryPage({

return (
<div className="w-full">
<div className="mb-4">
<h1 className="text-text-1">{org.name}</h1>
<p className="text-sm text-text-2 mt-1">
Institutional memory. Knowledge that outlives every board.
</p>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-text-1">{org.name}</h1>
<p className="text-sm text-text-2 mt-1">
Institutional memory. Knowledge that outlives every board.
</p>
</div>
{/*
The export lists every card and quotes only the ordinary ones — a
card marked above standard, and the retired Credential type, are
named with their contents withheld. The rule lives in the route,
beside the read; see api/export/orgs/[slug]/memory.
*/}
<ExportButton href={`/api/export/orgs/${slug}/memory`} label="this club's memory" />
</div>
<OrgTabs slug={slug} />

Expand Down
110 changes: 110 additions & 0 deletions apps/web/src/app/api/export/orgs/[slug]/documents/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { notFound } from "next/navigation"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import { canViewOrg, getUserContext } from "@/lib/rbac"
import { withTenantScope } from "@/lib/tenant-scope"
import { formatInZone } from "@/lib/time"
import { fileTypeLabel } from "@/lib/uploads"
import { reportContext, reportResponse } from "@/lib/export/report-response"
import {
clubDocumentsDocument,
documentsReportFilename,
type DocumentEntry,
} from "@/lib/export/reports/club-documents"

/**
* A club's document library as an INDEX — the catalogue, never the files.
*
* The gate is `canViewOrg`, the same predicate the documents page applies.
*
* `objectKey` is not selected. It is the raw storage key the schema says beside
* itself never to hand out, and a report is exactly the kind of artefact that
* would carry one out of the building unnoticed. The page's own list makes the
* same choice and says so.
*/
export const dynamic = "force-dynamic"

/** One more than will be printed, so a cap that BIT can be reported honestly. */
const LIST_CAP = 300

function formatBytes(n?: number | null) {
if (!n) return "—"
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}

export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const session = await auth()
if (!session?.user?.id) return new Response("Sign in to download this report", { status: 401 })
const userId = session.user.id

return withTenantScope(userId, async () => {
const { org, institutionName, generatedAt, preparedFor, timeZone, now } = await reportContext(
slug,
userId
)
const ctx = await getUserContext(userId)
if (!canViewOrg(ctx, org)) notFound()

const COLUMNS = {
title: true,
mimeType: true,
sizeBytes: true,
createdById: true,
createdAt: true,
} as const

const [live, archived] = await Promise.all([
db.document.findMany({
where: { organizationId: org.id, isArchived: false },
select: COLUMNS,
orderBy: { createdAt: "desc" },
take: LIST_CAP + 1,
}),
db.document.findMany({
where: { organizationId: org.id, isArchived: true },
select: COLUMNS,
orderBy: { createdAt: "desc" },
take: LIST_CAP + 1,
}),
])

const filerIds = [...new Set([...live, ...archived].flatMap((d) => (d.createdById ? [d.createdById] : [])))]
const filers = new Map(
(
await db.user.findMany({
where: { id: { in: filerIds } },
select: { id: true, name: true, email: true },
})
).map((u) => [u.id, u.name ?? u.email ?? "—"])
)

const truncated: string[] = []
const cut = <T,>(rows: T[], what: string): T[] => {
if (rows.length <= LIST_CAP) return rows
truncated.push(`${what} (${LIST_CAP} of ${rows.length}+ shown)`)
return rows.slice(0, LIST_CAP)
}

const entry = (d: (typeof live)[number]): DocumentEntry => ({
title: d.title,
kind: fileTypeLabel(d.mimeType),
size: formatBytes(d.sizeBytes),
filedBy: d.createdById ? (filers.get(d.createdById) ?? "—") : "—",
filed: formatInZone(d.createdAt, timeZone, { dateStyle: "medium" }),
})

const doc = clubDocumentsDocument({
clubName: org.name,
generatedAt,
preparedFor,
live: cut(live, "documents on file").map(entry),
archived: cut(archived, "archived documents").map(entry),
truncated,
})

return reportResponse(doc, documentsReportFilename(org.name, now), institutionName)
})
}
125 changes: 125 additions & 0 deletions apps/web/src/app/api/export/orgs/[slug]/impact/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { notFound } from "next/navigation"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import { canViewOrg, getUserContext } from "@/lib/rbac"
import { withTenantScope } from "@/lib/tenant-scope"
import { countCurrentHolders, summariseSeats } from "@/lib/seats"
import { loadSeatFacts } from "@/lib/seats-data"
import { currentTerm } from "@/lib/tenant/term"
import { formatCents, PLAN_LINE_WHERE } from "@/lib/finance"
import { reportContext, reportResponse } from "@/lib/export/report-response"
import { clubImpactDocument, impactReportFilename } from "@/lib/export/reports/club-impact"

/**
* A club's impact summary as a branded PDF — the export with a real audience
* outside Tenure: a sponsor, a dean, a national chapter.
*
* The gate is `canViewOrg`, the same predicate the impact page applies.
*
* ── EVERY FIGURE IS COMPUTED THE WAY THE PAGE COMPUTES IT ───────────────────
*
* Same loaders (`loadSeatFacts`, `summariseSeats`, `countCurrentHolders`), same
* `PLAN_LINE_WHERE` so retired budget lines are out of both, same bucketing of
* approval statuses including CANCELLED — which the page had to be corrected to
* count, and a second implementation here would have been free to make that
* mistake again in private.
*
* The one thing this does NOT do is recompute the narrative sentence. It is
* built here from the same parts in the same order as the page, because a
* document and a screen that disagree about the same year is worse than either
* being wrong alone.
*/
export const dynamic = "force-dynamic"

export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const session = await auth()
if (!session?.user?.id) return new Response("Sign in to download this report", { status: 401 })
const userId = session.user.id

return withTenantScope(userId, async () => {
const { org, institutionName, generatedAt, preparedFor, now, evaluatedAt } =
await reportContext(slug, userId)
const ctx = await getUserContext(userId)
if (!canViewOrg(ctx, org)) notFound()

const academicYear = currentTerm()
const [eventsPublished, approvalGroups, budgetLines, memoryCards, seatFacts, collaborations] =
await Promise.all([
db.event.count({ where: { organizationId: org.id, status: "PUBLISHED" } }),
db.approvalRequest.groupBy({
by: ["status"],
where: { organizationId: org.id },
_count: { _all: true },
}),
db.budgetLine.findMany({
where: { organizationId: org.id, academicYear, ...PLAN_LINE_WHERE },
select: { budgetedCents: true, actualCents: true },
}),
db.memoryRecord.count({ where: { organizationId: org.id, isArchived: false } }),
loadSeatFacts({ organizationId: org.id }, evaluatedAt),
db.collabInterest.count({ where: { organizationId: org.id, status: "APPROVED" } }),
])

const countBy = Object.fromEntries(approvalGroups.map((g) => [g.status, g._count._all]))
const approved = countBy["APPROVED"] ?? 0
const rejected = countBy["REJECTED"] ?? 0
const cancelled = countBy["CANCELLED"] ?? 0
const inFlight =
(countBy["PENDING_PRESIDENT"] ?? 0) +
(countBy["PENDING_OSE"] ?? 0) +
(countBy["NEEDS_CHANGES"] ?? 0) +
(countBy["DRAFT"] ?? 0)

// A withdrawn request was never decided, so folding it in here would
// depress a rate it says nothing about.
const decided = approved + rejected
const approvalRatePct = decided > 0 ? Math.round((approved / decided) * 100) : null

const budgeted = budgetLines.reduce((s, l) => s + l.budgetedCents, 0)
const actual = budgetLines.reduce((s, l) => s + l.actualCents, 0)

const seats = summariseSeats(seatFacts)
// The page uses `shortName ?? name`. Same sentence means same name.
const name = org.shortName ?? org.name

const narrative = [
`${name} published ${eventsPublished} event${eventsPublished === 1 ? "" : "s"}`,
`cleared ${approved} approval${approved === 1 ? "" : "s"}${
approvalRatePct !== null ? ` (${approvalRatePct}% approval rate)` : ""
}`,
budgeted > 0 ? `delivered ${formatCents(actual)} of a ${formatCents(budgeted)} budget` : null,
`and captured ${memoryCards} knowledge card${
memoryCards === 1 ? "" : "s"
} for the next board`,
]
.filter(Boolean)
.join(", ")

const doc = clubImpactDocument({
clubName: org.name,
academicYear,
generatedAt,
preparedFor,
narrative,
eventsPublished,
approved,
rejected,
cancelled,
inFlight,
approvalRatePct,
budgetedLabel: budgeted > 0 ? formatCents(budgeted) : "No budget set",
actualLabel: formatCents(actual),
// Null rather than 0 when there is no budget: 0% is a claim about
// spending discipline, and "no budget set" is a different fact.
budgetUsedPct: budgeted > 0 ? Math.round((actual / budgeted) * 100) : null,
activeMembers: countCurrentHolders(seatFacts),
seatsFilled: seats.filled,
seatsTotal: seats.total,
memoryCards,
collaborations,
})

return reportResponse(doc, impactReportFilename(org.name, now), institutionName)
})
}
Loading
Loading