diff --git a/apps/web/src/app/(app)/orgs/[slug]/members/page.tsx b/apps/web/src/app/(app)/orgs/[slug]/members/page.tsx
index 8f4a77a4..8304a5de 100644
--- a/apps/web/src/app/(app)/orgs/[slug]/members/page.tsx
+++ b/apps/web/src/app/(app)/orgs/[slug]/members/page.tsx
@@ -15,6 +15,7 @@ import { OrgTabs } from "@/components/OrgTabs"
import { ExportButton } from "@/components/export/ExportButton"
import { EmailLink } from "@/components/EmailLink"
import { ClubImageEditor } from "@/components/ClubImageEditor"
+import { ClubProfileEditor } from "@/components/ClubProfileEditor"
import { ConfirmSubmit } from "@/components/ui/ConfirmDialog"
import { TermDatesForm } from "@/components/TermDatesForm"
import { RosterAddForm } from "@/components/RosterAddForm"
@@ -124,6 +125,14 @@ export default async function MembersPage({
canUpload={storageConfigured()}
/>
)}
+ {canEditImage && (
+
+ )}
diff --git a/apps/web/src/app/(app)/orgs/actions.ts b/apps/web/src/app/(app)/orgs/actions.ts
index f722480e..46d962c9 100644
--- a/apps/web/src/app/(app)/orgs/actions.ts
+++ b/apps/web/src/app/(app)/orgs/actions.ts
@@ -9,7 +9,12 @@ import { requireCapability } from "@/lib/admin/guard"
import { withTenantScope } from "@/lib/tenant-scope"
import { storageConfigured, uploadDocument } from "@/lib/s3"
import { ACCEPT_IMAGE, MAX_IMAGE_BYTES, inspectUpload } from "@/lib/uploads"
-import { Refusal, reportable } from "@/lib/admin/action-state"
+import {
+ Refusal,
+ reportable,
+ reportingWith,
+ type DialogActionState,
+} from "@/lib/admin/action-state"
import { recordAuditEvent } from "@/lib/audit-record"
async function requireUserId() {
@@ -114,6 +119,98 @@ async function auditImage(org: { id: string; institutionId: string }, actorId: s
})
}
+/**
+ * A CLUB EDITS ITS OWN DESCRIPTION.
+ *
+ * ── The gap this closes ─────────────────────────────────────────────────────
+ *
+ * Before this, the only thing a club could change about itself was its IMAGE.
+ * Its description — the sentence every member, every prospective member and
+ * every export reads first — could be changed by exactly one person: an OSE
+ * Director, in `/admin/clubs`. A president looking at their own club's page saw
+ * a paragraph about their club that they had no way to correct.
+ *
+ * Nothing was broken, which is why it lasted. `requireOrgManager` below has
+ * always refused with "You do not have permission to edit this club" — a
+ * sentence written for an edit path that did not exist.
+ *
+ * ── WHAT IS DELIBERATELY NOT HERE ──────────────────────────────────────────
+ *
+ * `category` stays with the OSE. It is the institution's TAXONOMY — the thing
+ * clubs are grouped and compared by across the whole directory — so a club
+ * re-filing itself would change a number on somebody else's report. That is a
+ * different decision from describing yourself, and it belongs where the other
+ * institution-wide decisions are.
+ *
+ * `name` and `slug` stay too. A slug is in every link anybody has ever shared,
+ * and renaming a recognised organisation is an institution act with a record,
+ * not a text box.
+ *
+ * The club owns how it DESCRIBES itself. The institution owns how it is FILED.
+ */
+export const updateOrgProfile = reportingWith(async (formData: FormData) => {
+ const userId = await requireUserId()
+ return withTenantScope(userId, async () => {
+ const organizationId = String(formData.get("organizationId") ?? "")
+ const org = await requireOrgManager(userId, organizationId)
+
+ // Trimmed, and empty means "cleared" rather than "unchanged" — a club
+ // deleting its description on purpose must be able to.
+ const description = String(formData.get("description") ?? "").trim()
+ const shortName = String(formData.get("shortName") ?? "").trim()
+
+ if (description.length > 2000) {
+ throw new Refusal("That description is too long. Keep it under 2,000 characters.")
+ }
+ if (shortName.length > 80) {
+ throw new Refusal("That short name is too long. Keep it under 80 characters.")
+ }
+
+ /*
+ * THE EDIT AND ITS RECORD COMMIT TOGETHER, OR NEITHER DOES.
+ *
+ * The first version updated and then recorded. If the audit write failed,
+ * the description was already changed and the action reported failure — so
+ * the club's page showed new text, nothing in the log said who wrote it,
+ * and the person who did it had been told it did not work. Every later
+ * reading of that record is wrong, and nothing anywhere is red.
+ *
+ * `recordAuditEvent` takes its client as an argument precisely so it can be
+ * handed a transaction. The record says WHAT it was as well as that it
+ * changed: "somebody edited it" is not an answer to "who removed the
+ * accessibility note".
+ */
+ await db.$transaction(async (tx) => {
+ await tx.organization.update({
+ where: { id: org.id },
+ data: { description: description || null, shortName: shortName || null },
+ })
+ await recordAuditEvent(tx, {
+ institutionId: org.institutionId,
+ organizationId: org.id,
+ actorId: userId,
+ action: "Club.ProfileEdited",
+ resourceType: "Organization",
+ resourceId: org.id,
+ outcome: "ALLOW",
+ metadata: {
+ fromDescription: org.description,
+ toDescription: description || null,
+ fromShortName: org.shortName,
+ toShortName: shortName || null,
+ },
+ })
+ })
+
+ revalidatePath(`/orgs/${org.slug}/members`)
+ revalidatePath("/orgs")
+ revalidatePath("/admin/clubs")
+ // `ok` rather than `{}`: useActionState's initial value is also `{}`, so
+ // an empty object cannot tell success from 'nothing submitted yet'.
+ return { ok: true }
+ })
+})
+
/** Upload a club image to object storage; the /api/org-image proxy serves it. */
export const uploadOrgImage = reportable(async function uploadOrgImageImpl(formData: FormData) {
const userId = await requireUserId()
diff --git a/apps/web/src/components/ClubProfileEditor.tsx b/apps/web/src/components/ClubProfileEditor.tsx
new file mode 100644
index 00000000..a4948c52
--- /dev/null
+++ b/apps/web/src/components/ClubProfileEditor.tsx
@@ -0,0 +1,165 @@
+"use client"
+
+import { useActionState, useEffect, useState } from "react"
+import { AlertCircle, PenSquare } from "@/components/ui/icons"
+import { Overlay } from "@/components/ui/Overlay"
+import { updateOrgProfile } from "@/app/(app)/orgs/actions"
+import type { DialogActionState } from "@/lib/admin/action-state"
+
+/**
+ * A CLUB EDITS THE SENTENCE ITS OWN MEMBERS READ.
+ *
+ * ── The gap ─────────────────────────────────────────────────────────────────
+ *
+ * Found while sweeping for the defect the owner reported on the budget: a value
+ * on screen with no way to change it from where it is shown.
+ *
+ * A club's description is the first thing anybody reads about it — on its
+ * members page, in the club directory, and in the workspace export. Until now
+ * exactly one person could change it: an OSE Director, in `/admin/clubs`. A
+ * president reading a stale paragraph about their own club had no control
+ * anywhere, and no explanation of why not.
+ *
+ * The same shape as the budget cell: nothing disabled, nothing broken, nothing
+ * to report — just an affordance that was never there.
+ *
+ * ── Where the line is drawn, and why it is drawn there ──────────────────────
+ *
+ * The club owns how it DESCRIBES itself. The institution owns how it is FILED.
+ *
+ * So `description` and `shortName` are here, and `category` is not: category is
+ * the taxonomy the whole directory is grouped and counted by, so a club
+ * re-filing itself would move a figure on somebody else's report. `name` and
+ * `slug` are not here either — a slug sits inside every link anybody has ever
+ * shared of this club.
+ *
+ * The refusal is not in this component. `requireOrgManager` decides, on the
+ * server, on every submission; this only decides whether the button is drawn.
+ */
+export function ClubProfileEditor({
+ organizationId,
+ name,
+ shortName,
+ description,
+}: {
+ organizationId: string
+ name: string
+ shortName: string | null
+ description: string | null
+}) {
+ const [open, setOpen] = useState(false)
+ const [state, formAction, pending] = useActionState(
+ updateOrgProfile,
+ {}
+ )
+
+ // Controlled, so a refusal leaves what was typed exactly where it was —
+ // React empties an uncontrolled form when the action resolves, which would
+ // throw away a paragraph somebody had just written.
+ const [desc, setDesc] = useState(description ?? "")
+ const [short, setShort] = useState(shortName ?? "")
+
+ useEffect(() => {
+ /*
+ * DEPENDS ON `state`, NOT ON `state.ok`.
+ *
+ * `ok` is `true` after the first successful save and STAYS true — a second
+ * save returns a new state object carrying the same `true`, so an effect
+ * keyed on the boolean never runs again and the dialog stays open on every
+ * save after the first. `useActionState` returns a fresh object per
+ * submission, which is the thing that actually changes.
+ */
+ if (state.ok) setOpen(false)
+ }, [state])
+
+ return (
+ <>
+
+
+ {open && (
+ {
+ if (!next) setOpen(false)
+ }}
+ title={`About ${name}`}
+ description="This is what members and prospective members read first."
+ size="md"
+ >
+
+
+ )}
+ >
+ )
+}
diff --git a/apps/web/src/components/finance/BudgetLineEditor.tsx b/apps/web/src/components/finance/BudgetLineEditor.tsx
new file mode 100644
index 00000000..f599b4a4
--- /dev/null
+++ b/apps/web/src/components/finance/BudgetLineEditor.tsx
@@ -0,0 +1,265 @@
+"use client"
+
+import { useActionState, useEffect, useState } from "react"
+import { AlertCircle, CheckCircle } from "@/components/ui/icons"
+import { Overlay } from "@/components/ui/Overlay"
+import { ReasonField } from "@/components/forms/ReasonField"
+import { formatCents, parseMoneyToCents, REBUDGET_INTENT } from "@/lib/finance"
+import {
+ upsertBudgetLine,
+ type BudgetLineFormState,
+} from "@/app/(app)/orgs/[slug]/finance/actions"
+
+/**
+ * CHANGE A BUDGET FROM THE ROW IT IS ON.
+ *
+ * ── The report ──────────────────────────────────────────────────────────────
+ *
+ * "i still dont se any input section for budget. all isee is it hardcoded and
+ * i cant edit it."
+ *
+ * It was not hardcoded, and that is exactly why the report is fair. A treasurer
+ * COULD change an allocation — by scrolling past the table and the chart to a
+ * card headed "Add a budget line", retyping the category name exactly, and then
+ * confirming a re-budget question. Every one of those steps is a thing you have
+ * to already know.
+ *
+ * ── What the row was actually saying ────────────────────────────────────────
+ *
+ * Look at one line of that table. "Spent" is a button that opens the ledger
+ * behind the figure. "Projected" is a text input you can type in. "Budgeted" —
+ * the one number a treasurer is responsible for setting — was plain text, for
+ * managers and viewers alike.
+ *
+ * So the row taught its own lesson, and the lesson was wrong: two of these
+ * numbers are yours to touch, the third is not. Nothing was disabled and
+ * nothing was explained, because from the markup's point of view nothing was
+ * wrong. That is the shape of this defect — an affordance that is MISSING
+ * rather than broken, which no test fails over and no error ever reports.
+ *
+ * ── The design, and why it is a dialog rather than an inline input ──────────
+ *
+ * Structural: the whole table is already inside the forecast `ReportingForm`,
+ * and an HTML form cannot nest. A per-row `