From 2e55239894d3b148db2f4129a1004d2858b5a58f Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 07:53:51 +0000 Subject: [PATCH 1/6] orgs: split long panels into named parts, name magic values, un-export file-local symbols --- .../src/features/orgs/create-org-panel.tsx | 328 +++++------- .../admin/src/features/orgs/evidence-list.tsx | 11 +- .../admin/src/features/orgs/members-panel.tsx | 467 +++++++++--------- apps/admin/src/features/orgs/org-detail.tsx | 259 ++++++++++ .../src/features/orgs/org-events-panel.tsx | 109 ++-- apps/admin/src/features/orgs/org-facts.tsx | 60 +++ .../src/features/orgs/org-form-fields.tsx | 316 +++++++----- apps/admin/src/features/orgs/org-form.test.ts | 93 +++- apps/admin/src/features/orgs/org-form.ts | 143 +++--- apps/admin/src/features/orgs/org-list.tsx | 123 +++++ .../src/features/orgs/org-logo-upload.test.ts | 22 + .../src/features/orgs/org-logo-upload.ts | 16 +- apps/admin/src/features/orgs/org-logo.tsx | 27 + apps/admin/src/features/orgs/org-slug.test.ts | 8 +- apps/admin/src/features/orgs/org-slug.ts | 41 +- apps/admin/src/features/orgs/orgs-page.tsx | 396 +++------------ .../admin/src/features/orgs/profile-panel.tsx | 219 ++++---- .../src/features/orgs/use-create-org-form.ts | 122 +++++ apps/admin/src/features/orgs/use-orgs.ts | 15 +- apps/admin/src/features/orgs/use-row-menu.ts | 115 +++++ .../src/features/orgs/verification-panel.tsx | 339 ++++++------- 21 files changed, 1888 insertions(+), 1341 deletions(-) create mode 100644 apps/admin/src/features/orgs/org-detail.tsx create mode 100644 apps/admin/src/features/orgs/org-facts.tsx create mode 100644 apps/admin/src/features/orgs/org-list.tsx create mode 100644 apps/admin/src/features/orgs/org-logo.tsx create mode 100644 apps/admin/src/features/orgs/use-create-org-form.ts create mode 100644 apps/admin/src/features/orgs/use-row-menu.ts diff --git a/apps/admin/src/features/orgs/create-org-panel.tsx b/apps/admin/src/features/orgs/create-org-panel.tsx index 0cee38b..e605e66 100644 --- a/apps/admin/src/features/orgs/create-org-panel.tsx +++ b/apps/admin/src/features/orgs/create-org-panel.tsx @@ -1,20 +1,12 @@ "use client" import * as React from "react" -import type { AdminOrgDTO, OrgVerificationKind } from "@civfix/shared" +import { OrgVerificationKindSchema, type AdminOrgDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { usePristineDismiss } from "@/components/shared/backdrop-dismiss" import { useModalFocus } from "@/components/shared/modal-focus" -import { - buildCreateRequest, - clearChangedFieldErrors, - emptyProfileDraft, - fieldErrorsFromError, - validateProfileDraft, - type OrgProfileDraft, - type OrgProfileErrors, -} from "@/features/orgs/org-form" +import type { OrgProfileErrors } from "@/features/orgs/org-form" import { FieldError, OrgProfileFields, @@ -22,15 +14,21 @@ import { fieldErrorId, fieldHintId, } from "@/features/orgs/org-form-fields" -import { useCreateOrg } from "@/features/orgs/use-orgs" import { UserPicker, type PickedUser } from "@/features/orgs/user-picker" import { ORG_KIND_LABEL } from "@/features/orgs/org-verification" +import { useCreateOrgForm, type VerifiedKindChoice } from "@/features/orgs/use-create-org-form" + +const OWNER_FIELD_ID = "org-create-owner" +const OWNER_LABEL_ID = "org-create-owner-label" +const TITLE_ID = "org-create-title" +const VERIFIED_KIND_ID = "org-verified-kind" -const VERIFICATION_OPTIONS: { value: "" | OrgVerificationKind; label: string }[] = [ +const VERIFICATION_OPTIONS: { value: VerifiedKindChoice; label: string }[] = [ { value: "", label: "Leave unverified" }, - { value: "nonprofit", label: `Verified as ${ORG_KIND_LABEL.nonprofit.toLowerCase()}` }, - { value: "government", label: `Verified as ${ORG_KIND_LABEL.government.toLowerCase()}` }, - { value: "community", label: `Verified as ${ORG_KIND_LABEL.community.toLowerCase()}` }, + ...OrgVerificationKindSchema.options.map((kind) => ({ + value: kind, + label: `Verified as ${ORG_KIND_LABEL[kind].toLowerCase()}`, + })), ] /** @@ -50,23 +48,87 @@ export function CreateOrgPanel({ return } -function validateCreate( - draft: OrgProfileDraft, - owner: PickedUser | null, - reason: string, -): OrgProfileErrors { - const errors = validateProfileDraft(draft) - if (!owner) errors.ownerUserId = "Pick the person who owns this organization." - if (reason.trim() === "") errors.reason = "A reason is required." - return errors +function FormSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ) +} + +function OwnerSection({ + owner, + onChange, + errors, +}: { + owner: PickedUser | null + onChange: (next: PickedUser | null) => void + errors: OrgProfileErrors +}) { + return ( + +
+ + Owner + an existing civfix account + + + + + The owner can edit the organization, manage its members and host under its name. + Ownership can be transferred later from the Members tab. + +
+
+ ) +} + +/** The verification shortcut is for operator-onboarded partners (DECISIONS §32). */ +function VerificationSection({ + value, + onChange, + disabled, +}: { + value: VerifiedKindChoice + onChange: (next: VerifiedKindChoice) => void + disabled: boolean +}) { + return ( + +
+ + + + {value === "" + ? "The organization can apply for verification itself from its settings." + : "Created already verified, with no evidence round-trip. Use for partners you onboard directly (a city department, a known nonprofit)."} + +
+
+ ) } -/** - * The verification shortcut is for operator-onboarded partners (DECISIONS §32). Local validation - * errors are recomputed live once the operator has tried to submit; server errors (slug conflict, - * VALIDATION.fields) are held apart and cleared per field as that field changes, so a message never - * outlives the value it was about. - */ function CreateOrgSlideOver({ onClose, onCreated, @@ -74,84 +136,11 @@ function CreateOrgSlideOver({ onClose: () => void onCreated: (org: AdminOrgDTO) => void }) { - const create = useCreateOrg() - const [draft, setDraft] = React.useState(emptyProfileDraft) - const [owner, setOwnerState] = React.useState(null) - const [verifiedKind, setVerifiedKind] = React.useState<"" | OrgVerificationKind>("") - const [reason, setReasonState] = React.useState("") - const [serverErrors, setServerErrors] = React.useState({}) - const [submitted, setSubmitted] = React.useState(false) - const [logoUploading, setLogoUploading] = React.useState(false) - // Set synchronously on submit, before React has re-rendered with `create.isPending`, so a second - // Enter in the same frame cannot start a second POST. - const inFlight = React.useRef(false) - - const pending = create.isPending - // Overlay click, Escape, the close button and Cancel all come through here: none of them may - // dismiss the form while the request is in flight, nor while a logo upload would be orphaned. - const close = React.useCallback(() => { - if (inFlight.current || pending || logoUploading) return - onClose() - }, [onClose, pending, logoUploading]) - - const pristine = - !logoUploading && - owner === null && - draft.logoMediaId === null && - verifiedKind === "" && - reason === "" && - Object.values(draft.social).every((v) => v === "") && - draft.name === "" && - draft.slug === "" && - draft.description === "" && - draft.websiteUrl === "" - const backdrop = usePristineDismiss(close, pristine) + const form = useCreateOrgForm({ onClose, onCreated }) + const { errors, pending, logoUploading, close } = form + const backdrop = usePristineDismiss(close, form.pristine) const panelRef = useModalFocus(true) - - const localErrors = React.useMemo( - () => (submitted ? validateCreate(draft, owner, reason) : {}), - [submitted, draft, owner, reason], - ) - const errors = React.useMemo( - () => ({ ...localErrors, ...serverErrors }), - [localErrors, serverErrors], - ) - - const submit = (e: React.FormEvent) => { - e.preventDefault() - if (inFlight.current || pending || logoUploading) return - setSubmitted(true) - if (Object.keys(validateCreate(draft, owner, reason)).length > 0 || !owner) return - const request = buildCreateRequest(draft, { - ownerUserId: owner.id, - verifiedKind: verifiedKind === "" ? null : verifiedKind, - reason, - }) - inFlight.current = true - setServerErrors({}) - create.mutate(request, { - onSuccess: (org) => onCreated(org), - // Every key fieldErrorsFromError can produce has a field in this form, so whatever it maps is - // shown inline; useCreateOrg toasts anything else. - onError: (err) => setServerErrors(fieldErrorsFromError(err, request)), - onSettled: () => { - inFlight.current = false - }, - }) - } - - const onDraftChange = (next: OrgProfileDraft) => { - setServerErrors((prev) => clearChangedFieldErrors(prev, draft, next)) - setDraft(next) - } - const setOwner = (next: PickedUser | null) => { - setOwnerState(next) - setServerErrors(({ ownerUserId: _o, ...rest }) => rest) - } - const setReason = (next: string) => { - setReasonState(next) - setServerErrors(({ reason: _r, ...rest }) => rest) - } + const locked = pending || logoUploading return ( <> @@ -161,14 +150,14 @@ function CreateOrgSlideOver({ className="panel org-create-panel open" role="dialog" aria-modal="true" - aria-labelledby="org-create-title" + aria-labelledby={TITLE_ID} aria-busy={pending} > -
+
Organizations
-

New organization

+

New organization

@@ -185,91 +174,33 @@ function CreateOrgSlideOver({
-
-
Profile
-
- -
-
+ + +
-
-
Owner
-
-
- - Owner - an existing civfix account - - - - - The owner can edit the organization, manage its members and host under its - name. Ownership can be transferred later from the Members tab. - -
-
-
- -
-
Verification
-
-
- - - - {verifiedKind === "" - ? "The organization can apply for verification itself from its settings." - : "Created already verified, with no evidence round-trip. Use for partners you onboard directly (a city department, a known nonprofit)."} - -
-
-
- -
-
Audit
-
- -
-
+ + + + +
@@ -277,15 +208,10 @@ function CreateOrgSlideOver({
{logoUploading && Uploading the logo…}
- -
diff --git a/apps/admin/src/features/orgs/evidence-list.tsx b/apps/admin/src/features/orgs/evidence-list.tsx index 43efbd6..6235f80 100644 --- a/apps/admin/src/features/orgs/evidence-list.tsx +++ b/apps/admin/src/features/orgs/evidence-list.tsx @@ -9,8 +9,11 @@ import { formatDateTime } from "@/lib/dates" import { evidenceUrlRemainingMs, isEvidenceUrlExpired } from "@/features/orgs/evidence-cache" import { useOrgVerificationDocument } from "@/features/orgs/use-orgs" +const SHORT_MEDIA_ID_LENGTH = 8 +const UUID_HYPHENS = /-/g + function shortMediaId(id: string): string { - return id.replace(/-/g, "").slice(0, 8) + return id.replace(UUID_HYPHENS, "").slice(0, SHORT_MEDIA_ID_LENGTH) } function EvidenceViewer({ mediaId, index }: { mediaId: string; index: number }) { @@ -73,15 +76,11 @@ function EvidenceViewer({ mediaId, index }: { mediaId: string; index: number }) ) } +/** Holds which document is open; the parent keys it by the id list so a new application starts closed. */ export function EvidenceList({ mediaIds }: { mediaIds: string[] }) { const [openId, setOpenId] = React.useState(null) const viewerIdPrefix = React.useId() - const mediaKey = mediaIds.join(",") - React.useEffect(() => { - setOpenId(null) - }, [mediaKey]) - if (mediaIds.length === 0) { return ( q.data?.pages.flatMap((p) => p.items) ?? [], [q.data]) const ownerName = members.find((m) => m.role === "owner")?.user.name ?? org.owner?.name ?? null + return { q, members, ownerName } +} + +type Roster = ReturnType + +function RosterBody({ org, roster: { q, members, ownerName } }: { org: AdminOrgDTO; roster: Roster }) { + if (q.isLoading) return + if (q.isError && !q.data) { + return q.refetch()} title="Could not load members" /> + } + if (members.length === 0) { + return ( + } + /> + ) + } + return ( + <> + {members.map((m) => ( + + ))} + {q.hasNextPage && ( + + )} + + ) +} + +export function MembersPanel({ org }: { org: AdminOrgDTO }) { + const roster = useRoster(org) + const { q, members, ownerName } = roster const memberIds = React.useMemo(() => new Set(members.map((m) => m.user.id)), [members]) const [adding, setAdding] = React.useState(false) const suspended = !!org.suspendedAt @@ -72,134 +119,42 @@ export function MembersPanel({ org }: { org: AdminOrgDTO }) {
- {q.isLoading ? ( - - ) : q.isError && !q.data ? ( - q.refetch()} title="Could not load members" /> - ) : members.length === 0 ? ( - } - /> - ) : ( - <> - {members.map((m) => ( - - ))} - {q.hasNextPage && ( - - )} - - )} +
) } -function MemberRow({ - org, - member, - ownerName, -}: { - org: AdminOrgDTO - member: AdminOrgMemberDTO - ownerName: string | null -}) { - const nav = useNav() +interface ReasonPrompt { + title: string + body: string + placeholder: string + confirmLabel: string | undefined + danger: boolean +} + +function useMemberActions(org: AdminOrgDTO, member: AdminOrgMemberDTO, ownerName: string | null) { const setRole = useSetOrgMemberRole() const remove = useRemoveOrgMember() - // The menu is position:fixed (anchored to the trigger's rect) so the card's overflow:hidden and the - // roster's rounded clip cannot cut it off; it flips upward when the trigger is near the bottom. - const [menuPos, setMenuPos] = React.useState<{ top?: number; bottom?: number; right: number } | null>( - null, - ) - const wrapRef = React.useRef(null) - const popRef = React.useRef(null) - const triggerRef = React.useRef(null) // True from a menu choice until its dialog resolves: the menu is closed by then, but a keyboard // user can reopen it and pick again while the prompt is still up. const prompting = React.useRef(false) - const menuOpen = menuPos !== null const busy = setRole.isPending || remove.isPending - // The backend refuses every change to the owner's own membership: ownership moves by promoting - // someone else, so the owner row has no actions. - const isOwner = member.role === "owner" - - const openMenu = () => { - const rect = triggerRef.current?.getBoundingClientRect() - if (!rect) return - const right = Math.max(8, window.innerWidth - rect.right) - const flipUp = rect.bottom + 220 > window.innerHeight - setMenuPos(flipUp ? { bottom: window.innerHeight - rect.top + 4, right } : { top: rect.bottom + 4, right }) - } - const closeMenu = React.useCallback((returnFocus: boolean) => { - setMenuPos(null) - if (returnFocus) triggerRef.current?.focus({ preventScroll: true }) - }, []) - const menuItems = () => - Array.from( - popRef.current?.querySelectorAll('[role="menuitem"]') ?? [], - ) - - // A real menu: the first item takes focus on open; arrows/Home/End move (wrapping), Escape closes - // and returns focus to the trigger, and focus leaving the menu (Tab, a click elsewhere) closes it. - React.useEffect(() => { - if (!menuOpen) return - menuItems()[0]?.focus({ preventScroll: true }) - const onDoc = (e: MouseEvent) => { - if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) closeMenu(false) - } - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") { - // The menu owns this Escape: nothing above it (the shell's go-home) should also react. - e.preventDefault() - e.stopPropagation() - closeMenu(true) - } - } - const onScroll = () => closeMenu(false) - document.addEventListener("mousedown", onDoc) - document.addEventListener("keydown", onKey) - window.addEventListener("scroll", onScroll, true) - window.addEventListener("resize", onScroll) - return () => { - document.removeEventListener("mousedown", onDoc) - document.removeEventListener("keydown", onKey) - window.removeEventListener("scroll", onScroll, true) - window.removeEventListener("resize", onScroll) + const askReason = async (prompt: ReasonPrompt): Promise => { + prompting.current = true + let reason: string | null + try { + reason = await promptDialog({ ...prompt, label: REASON_LABEL, required: true }) + } finally { + prompting.current = false } - }, [menuOpen, closeMenu]) - - const onMenuKeyDown = (e: React.KeyboardEvent) => { - const items = menuItems() - const current = items.findIndex((el) => el === document.activeElement) - const next = menuFocusIndex(e.key, current, items.length) - if (next === null) return - e.preventDefault() - items[next]?.focus({ preventScroll: true }) - } - const onMenuBlur = (e: React.FocusEvent) => { - const to = e.relatedTarget as Node | null - if (!to || !wrapRef.current?.contains(to)) closeMenu(false) + const trimmed = reason?.trim() ?? "" + return trimmed === "" ? null : trimmed } - const onChangeRole = async (to: OrganizationMemberRole) => { - closeMenu(true) + const changeRole = async (to: OrganizationMemberRole) => { if (prompting.current || busy) return const copy = roleChangeCopy({ memberName: member.user.name, @@ -208,62 +163,114 @@ function MemberRow({ orgName: org.name, currentOwnerName: ownerName, }) - prompting.current = true - let reason: string | null - try { - reason = await promptDialog({ - title: copy.title, - body: copy.body, - label: "Reason (required)", - placeholder: copy.transfer - ? "Founder stepped down; board appointed a new lead…" - : "Requested by the organization's owner…", - confirmLabel: copy.confirmLabel, - required: true, - danger: copy.transfer, - }) - } finally { - prompting.current = false - } - if (reason === null || reason.trim() === "") return + const reason = await askReason({ + title: copy.title, + body: copy.body, + placeholder: copy.transfer + ? "Founder stepped down; board appointed a new lead…" + : "Requested by the organization's owner…", + confirmLabel: copy.confirmLabel, + danger: copy.transfer, + }) + if (reason === null) return setRole.mutate({ - request: { id: org.id, userId: member.user.id, role: to, reason: reason.trim() }, + request: { id: org.id, userId: member.user.id, role: to, reason }, memberName: member.user.name, orgName: org.name, }) } - const onRemove = async () => { - closeMenu(true) + const removeMember = async () => { if (prompting.current || busy) return - prompting.current = true - let reason: string | null - try { - reason = await promptDialog({ - title: `Remove ${member.user.name} from ${org.name}?`, - body: "They lose access to the organization, its events and its broadcasts immediately. Their account is untouched. The reason is written to the audit log.", - label: "Reason (required)", - placeholder: "Left the organization; confirmed by the owner…", - confirmLabel: "Remove member", - required: true, - danger: true, - }) - } finally { - prompting.current = false - } - if (reason === null || reason.trim() === "") return + const reason = await askReason({ + title: `Remove ${member.user.name} from ${org.name}?`, + body: "They lose access to the organization, its events and its broadcasts immediately. Their account is untouched. The reason is written to the audit log.", + placeholder: "Left the organization; confirmed by the owner…", + confirmLabel: "Remove member", + danger: true, + }) + if (reason === null) return remove.mutate({ - request: { id: org.id, userId: member.user.id, reason: reason.trim() }, + request: { id: org.id, userId: member.user.id, reason }, memberName: member.user.name, orgName: org.name, }) } + return { busy, changeRole, removeMember } +} + +function MemberActionsMenu({ + member, + menu, + onChangeRole, + onRemove, +}: { + member: AdminOrgMemberDTO + menu: RowMenu + onChangeRole: (to: OrganizationMemberRole) => void + onRemove: () => void +}) { + if (!menu.position) return null + return ( +
+
+ Change role +
+ {roleTargets(member.role).map((r) => ( + + ))} +
+ +
+ ) +} + +function MemberRow({ + org, + member, + ownerName, +}: { + org: AdminOrgDTO + member: AdminOrgMemberDTO + ownerName: string | null +}) { + const nav = useNav() + const menu = useRowMenu() + const actions = useMemberActions(org, member, ownerName) + // The backend refuses every change to the owner's own membership: ownership moves by promoting + // someone else, so the owner row has no actions. + const isOwner = member.role === "owner" const picked: PickedUser = { id: member.user.id, name: member.user.name, handle: member.user.handle } + const openProfile = () => nav("users", member.user.id) return (
- +
nav("users", member.user.id)} + onClick={openProfile} onKeyDown={(e) => { if (!isKeyboardActivationKey(e.key)) return e.preventDefault() - nav("users", member.user.id) + openProfile() }} > {member.user.name} @@ -294,75 +301,78 @@ function MemberRow({
- {member.role === "owner" && } {ORG_ROLE_LABEL[member.role]} + {isOwner && } {ORG_ROLE_LABEL[member.role]} -
+
- {menuPos && ( -
-
- Change role -
- {roleTargets(member.role).map((r) => ( - - ))} -
- -
- )} + { + menu.close(true) + void actions.changeRole(to) + }} + onRemove={() => { + menu.close(true) + void actions.removeMember() + }} + />
) } +function RoleField({ + role, + onChange, + ownerName, + disabled, +}: { + role: OrganizationMemberRole + onChange: (role: OrganizationMemberRole) => void + ownerName: string | null + disabled: boolean +}) { + return ( +
+ + + {role === "owner" && ( + + Choosing owner transfers ownership:{" "} + {ownerName ?? CURRENT_OWNER_FALLBACK} becomes an admin. + + )} +
+ ) +} + function AddMemberForm({ org, ownerName, @@ -395,7 +405,7 @@ function AddMemberForm({ try { ok = await confirmDialog({ title: `Transfer ownership of ${org.name} to ${user.name}?`, - body: `An organization has exactly one owner. ${user.name} becomes the owner and ${ownerName ?? "the current owner"} becomes an admin in the same change.`, + body: `An organization has exactly one owner. ${user.name} becomes the owner and ${ownerName ?? CURRENT_OWNER_FALLBACK} becomes an admin in the same change.`, confirmLabel: "Transfer ownership", danger: true, }) @@ -421,38 +431,21 @@ function AddMemberForm({
- + Person - -
-
- - - {transfer && ( - - Choosing owner transfers ownership:{" "} - {ownerName ?? "the current owner"} becomes an admin. - - )} +
+ = { + profile: "Profile", + verification: "Verification", + members: "Members", + events: "Events", +} + +function SuspendButton({ org }: { org: AdminOrgDTO }) { + const suspend = useSetOrgSuspended() + // Guards the button against a second click while the reason prompt is open. + const busy = React.useRef(false) + const suspended = !!org.suspendedAt + + const onSuspend = async () => { + if (busy.current || suspend.isPending) return + busy.current = true + let reason: string | null + try { + reason = await promptDialog({ + title: suspended ? `Restore ${org.name}?` : `Suspend ${org.name}?`, + body: suspended + ? "Lifting the suspension restores exactly what was there: verification, members and profile settings are untouched. The reason is written to the audit log." + : "A suspended organization keeps its data and members, but every write under its name (events, broadcasts, invites) is refused until it is restored. Its public page shows a notice. The reason is written to the audit log.", + label: "Reason (required)", + placeholder: suspended + ? "Resolved after the org replaced its contact…" + : "Repeated broadcast abuse reports; pending review with the org…", + confirmLabel: suspended ? "Restore organization" : "Suspend organization", + required: true, + danger: !suspended, + }) + } finally { + busy.current = false + } + if (reason === null || reason.trim() === "") return + suspend.mutate({ id: org.id, suspended: !suspended, reason: reason.trim() }) + } + + return ( + + ) +} + +function OrgDetailHeader({ org }: { org: AdminOrgDTO }) { + const statusView = orgStatusView(org.verifiedStatus) + return ( + <> +
+ + + +
+
+ /{org.slug} + {org.verifiedKind ? ` · ${ORG_KIND_LABEL[org.verifiedKind]}` : ""} +
+

{org.name}

+
+
+ {!!org.suspendedAt && ( + + Suspended + + )} + {statusView.label} +
+
+ +
+ + View public page + +
+ +
+ + ) +} + +function OrgBanners({ + org, + refreshFailed, + onRetry, +}: { + org: AdminOrgDTO + refreshFailed: boolean + onRetry: () => void +}) { + return ( + <> + {!!org.suspendedAt && ( +
+ +
+ Suspended {formatDateTime(org.suspendedAt)}.{" "} + {org.suspendedReason ? `“${org.suspendedReason}”` : "No reason recorded."} Writes under + this organization are refused until it is restored. +
+
+ )} + {refreshFailed && ( +
+ +
+ Could not refresh this organization. Showing what was last loaded.{" "} + +
+
+ )} + {org.deletedAt && ( +
+ +
+ Deleted {formatDateTime(org.deletedAt)}. This organization was soft-deleted; it is + read-only here. +
+
+ )} + + ) +} + +function TabBadge({ org, tab }: { org: AdminOrgDTO; tab: OrgDetailTab }) { + if (tab === "members") return {org.memberCount} + if (tab === "events") return {org.eventCount} + if (tab === "verification" && org.verifiedStatus === "pending") { + return ( + + ! + + ) + } + return null +} + +/** Roving-focus tablist: arrows move selection and focus together, wrapping at either end. */ +function OrgDetailTabs({ + org, + tab, + onTab, + tabId, + panelId, +}: { + org: AdminOrgDTO + tab: OrgDetailTab + onTab: (t: OrgDetailTab) => void + tabId: (t: OrgDetailTab) => string + panelId: string +}) { + const tabRefs = React.useRef(new Map()) + + const onTabKeyDown = (e: React.KeyboardEvent) => { + const current = ORG_DETAIL_TABS.indexOf(tab) + const next = menuFocusIndex(e.key, current, ORG_DETAIL_TABS.length, "horizontal") + if (next === null) return + e.preventDefault() + const target = ORG_DETAIL_TABS[next]! + onTab(target) + tabRefs.current.get(target)?.focus() + } + + return ( +
+ {ORG_DETAIL_TABS.map((t) => ( + + ))} +
+ ) +} + +export function OrgDetail({ + orgId, + tab, + onTab, +}: { + orgId: string + tab: OrgDetailTab + onTab: (t: OrgDetailTab) => void +}) { + const q = useAdminOrg(orgId) + const tabIds = React.useId() + + if (q.isLoading) return + if (q.isError && !q.data) { + return ( + q.refetch()} title="Could not load this organization" /> + ) + } + const org = q.data + if (!org) return null + const tabId = (t: OrgDetailTab) => `${tabIds}-tab-${t}` + const panelId = `${tabIds}-panel` + + return ( +
+ + void q.refetch()} /> + +
+ {tab === "profile" && } + {tab === "verification" && } + {tab === "members" && } + {tab === "events" && } +
+
+ ) +} diff --git a/apps/admin/src/features/orgs/org-events-panel.tsx b/apps/admin/src/features/orgs/org-events-panel.tsx index 0403184..d162748 100644 --- a/apps/admin/src/features/orgs/org-events-panel.tsx +++ b/apps/admin/src/features/orgs/org-events-panel.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import type { AdminOrgDTO, AdminOrgEventWhen } from "@civfix/shared" +import type { AdminOrgDTO, AdminOrgEventListResponse, AdminOrgEventWhen } from "@civfix/shared" import { Icons } from "@/components/icons" import { LoadingState, ErrorState } from "@/components/shared/data-states" @@ -18,6 +18,59 @@ const WHEN_OPTIONS: { value: AdminOrgEventWhen; label: string }[] = [ { value: "all", label: "All" }, ] +type OrgEventItem = AdminOrgEventListResponse["items"][number] + +function OrgEventRow({ item, onOpen }: { item: OrgEventItem; onOpen: () => void }) { + const kind = eventKindView(item.eventKind) + const KindIco = kind.icon + const status = eventStatusView(item.status) + return ( +
{ + if (!isKeyboardActivationKey(e.key)) return + e.preventDefault() + onOpen() + }} + > +
+ + + +
+
+
+ {item.title} + {item.flagged && ( + + + + )} +
+
+ {item.place} + · + + {item.attendees} + {item.capacity !== null ? `/${item.capacity}` : ""} attending + + · + {item.organizer.name} +
+
+
+ {status.label} + {item.date.abs} + +
+
+ ) +} + export function OrgEventsPanel({ org }: { org: AdminOrgDTO }) { const [when, setWhen] = React.useState("upcoming") const q = useOrgEventsInfinite(org.id, when) @@ -60,57 +113,9 @@ export function OrgEventsPanel({ org }: { org: AdminOrgDTO }) { /> ) : ( <> - {items.map((item) => { - const kind = eventKindView(item.eventKind) - const KindIco = kind.icon - const status = eventStatusView(item.status) - return ( -
nav("events", item.id)} - onKeyDown={(e) => { - if (!isKeyboardActivationKey(e.key)) return - e.preventDefault() - nav("events", item.id) - }} - > -
- - - -
-
-
- {item.title} - {item.flagged && ( - - - - )} -
-
- {item.place} - · - - {item.attendees} - {item.capacity !== null ? `/${item.capacity}` : ""} attending - - · - {item.organizer.name} -
-
-
- {status.label} - {item.date.abs} - -
-
- ) - })} + {items.map((item) => ( + nav("events", item.id)} /> + ))} {q.hasNextPage && (
+ ) +} + +/** Only an https url becomes a link; anything else stored is shown as text so it is never hidden. */ +export function UrlFact({ url }: { url: string | null | undefined }) { + if (isHttpsUrl(url)) { + return ( + + {url} + + ) + } + return <>{url || EMPTY_VALUE} +} + +export function kindLabel(kind: OrgVerificationKind | null | undefined): string { + return kind ? ORG_KIND_LABEL[kind] : EMPTY_VALUE +} + +export function DeletedFact({ deletedAt }: { deletedAt: string | null | undefined }) { + if (!deletedAt) return null + return {formatDateTime(deletedAt)} +} + +export function OrgStatusSubHead({ title, org }: { title: string; org: Pick }) { + const statusView = orgStatusView(org.verifiedStatus) + return ( +
+ {title} + + {statusView.label} + +
+ ) +} diff --git a/apps/admin/src/features/orgs/org-form-fields.tsx b/apps/admin/src/features/orgs/org-form-fields.tsx index 831b121..2853c5c 100644 --- a/apps/admin/src/features/orgs/org-form-fields.tsx +++ b/apps/admin/src/features/orgs/org-form-fields.tsx @@ -1,17 +1,20 @@ "use client" import * as React from "react" -import { SOCIAL_PLATFORM_LABELS, monogram } from "@civfix/shared" - -import { Icons } from "@/components/icons" import { MAX_ORG_DESCRIPTION, MAX_ORG_NAME, - SOCIAL_PLACEHOLDER, + SOCIAL_PLATFORM_LABELS, SOCIAL_PLATFORMS, + monogram, + type SocialPlatform, +} from "@civfix/shared" + +import { Icons } from "@/components/icons" +import { + SOCIAL_PLACEHOLDER, type OrgProfileDraft, type OrgProfileErrors, - type SocialPlatform, } from "@/features/orgs/org-form" import { MAX_ORG_LOGO_LABEL, @@ -19,9 +22,26 @@ import { logoFileProblem, logoUploadErrorMessage, } from "@/features/orgs/org-logo-upload" -import { deriveSlug, publicOrgUrl } from "@/features/orgs/org-slug" +import { + PUBLIC_ORG_URL_LABEL, + SLUG_RULES_HINT, + deriveSlug, + normalizeSlug, + publicOrgUrl, +} from "@/features/orgs/org-slug" import { useUploadOrgLogo } from "@/features/orgs/use-orgs" +// Mirrors AdminReasonSchema in @civfix/shared, which exports no constant for it. +const ADMIN_REASON_MAX_LENGTH = 1000 +const DESCRIPTION_ROWS = 4 +const REASON_ROWS = 2 + +type FormMode = "create" | "edit" + +function orgFieldId(mode: FormMode, name: string): string { + return `org-${mode}-${name}` +} + export function fieldErrorId(fieldId: string): string { return `${fieldId}-error` } @@ -52,23 +72,18 @@ export function FieldError({ id, text }: { id: string; text: string | null | und ) } -export function LogoField({ - draft, - error, - onChange, - mode, - disabled, - onUploadingChange, -}: { +interface LogoUploadOptions { draft: OrgProfileDraft - error: string | undefined onChange: (next: OrgProfileDraft) => void - mode: "create" | "edit" - disabled?: boolean - onUploadingChange?: (uploading: boolean) => void -}) { + onUploadingChange: ((uploading: boolean) => void) | undefined +} + +/** + * The upload outlives the render that started it, so its result is merged into the latest draft (not + * the one captured at pick time) and dropped entirely once the field has unmounted. + */ +function useLogoUpload({ draft, onChange, onUploadingChange }: LogoUploadOptions) { const upload = useUploadOrgLogo() - const inputRef = React.useRef(null) const objectUrlRef = React.useRef(null) const latest = React.useRef(draft) const mounted = React.useRef(true) @@ -94,7 +109,7 @@ export function LogoField({ objectUrlRef.current = null } - const onPick = (file: File) => { + const pick = (file: File) => { setProblem(null) const bad = logoFileProblem(file) if (bad) { @@ -116,16 +131,36 @@ export function LogoField({ }) } - const onRemove = () => { + const remove = () => { setProblem(null) releasePreview() onChange({ ...latest.current, logoMediaId: null, logoPreviewUrl: null }) } + return { uploading, problem, pick, remove } +} + +function LogoField({ + draft, + error, + onChange, + mode, + disabled, + onUploadingChange, +}: { + draft: OrgProfileDraft + error: string | undefined + onChange: (next: OrgProfileDraft) => void + mode: FormMode + disabled?: boolean + onUploadingChange?: (uploading: boolean) => void +}) { + const { uploading, problem, pick, remove } = useLogoUpload({ draft, onChange, onUploadingChange }) + const inputRef = React.useRef(null) const busy = !!disabled || uploading const hasLogo = draft.logoPreviewUrl !== null || draft.logoMediaId !== null const message = problem ?? error - const inputId = `org-${mode}-logo` + const inputId = orgFieldId(mode, "logo") return (
@@ -152,7 +187,7 @@ export function LogoField({ onChange={(e) => { const file = e.target.files?.[0] ?? null e.target.value = "" - if (file) onPick(file) + if (file) pick(file) }} /> )} @@ -182,11 +217,136 @@ export function LogoField({ ) } +interface DraftFieldProps { + draft: OrgProfileDraft + errors: OrgProfileErrors + onChange: (next: OrgProfileDraft) => void + mode: FormMode + disabled: boolean | undefined +} + /** * In create mode the slug follows the name until the operator edits it by hand; in edit mode the slug * never auto-changes and a change is called out because it breaks every existing link to the public * page. */ +function SlugField({ + draft, + errors, + onChange, + mode, + disabled, + slugTouched, + setSlugTouched, +}: DraftFieldProps & { slugTouched: boolean; setSlugTouched: (touched: boolean) => void }) { + const inputId = orgFieldId(mode, "slug") + const slugPreview = normalizeSlug(draft.slug) + const slugWarningId = `${inputId}-warning` + const slugHintIds = [fieldHintId(inputId), ...(mode === "edit" ? [slugWarningId] : [])] + + return ( +
+ +
+ {PUBLIC_ORG_URL_LABEL} + { + setSlugTouched(true) + onChange({ ...draft, slug: e.target.value.toLowerCase() }) + }} + /> + {mode === "create" && slugTouched && draft.name.trim() !== "" && ( + + )} +
+ {errors.slug ? ( + + ) : ( + + {slugPreview !== "" ? ( + <> + Public page: {publicOrgUrl(slugPreview)} + + ) : ( + SLUG_RULES_HINT + )} + + )} + {mode === "edit" && ( + + Changing the slug breaks existing links to the public + page, QR codes and shared signup pages. + + )} +
+ ) +} + +function SocialLinksField({ draft, errors, onChange, mode, disabled }: DraftFieldProps) { + const setSocial = (p: SocialPlatform, v: string) => + onChange({ ...draft, social: { ...draft.social, [p]: v } }) + + return ( +
+ + Social links + optional · handles, not URLs + +
+ {SOCIAL_PLATFORMS.map((p) => { + const inputId = orgFieldId(mode, `social-${p}`) + return ( +
+ +
+ {p === "whatsapp" ? "+" : "@"} + setSocial(p, e.target.value)} + {...fieldA11y(inputId, errors[p])} + /> +
+ +
+ ) + })} +
+
+ ) +} + export function OrgProfileFields({ draft, errors, @@ -198,22 +358,18 @@ export function OrgProfileFields({ draft: OrgProfileDraft errors: OrgProfileErrors onChange: (next: OrgProfileDraft) => void - mode: "create" | "edit" + mode: FormMode disabled?: boolean onLogoUploadingChange?: (uploading: boolean) => void }) { const [slugTouched, setSlugTouched] = React.useState(mode === "edit") - const id = (name: string) => `org-${mode}-${name}` - const slugPreview = draft.slug.trim().toLowerCase() - const slugWarningId = `${id("slug")}-warning` - const slugHintIds = [fieldHintId(id("slug")), ...(mode === "edit" ? [slugWarningId] : [])] + const id = (name: string) => orgFieldId(mode, name) + const fieldProps: DraftFieldProps = { draft, errors, onChange, mode, disabled } const setName = (name: string) => { if (mode === "create" && !slugTouched) onChange({ ...draft, name, slug: deriveSlug(name) }) else onChange({ ...draft, name }) } - const setSocial = (p: SocialPlatform, v: string) => - onChange({ ...draft, social: { ...draft.social, [p]: v } }) return ( <> @@ -234,63 +390,7 @@ export function OrgProfileFields({
-
- -
- civfix.org/orgs/ - { - setSlugTouched(true) - onChange({ ...draft, slug: e.target.value.toLowerCase() }) - }} - /> - {mode === "create" && slugTouched && draft.name.trim() !== "" && ( - - )} -
- {errors.slug ? ( - - ) : ( - - {slugPreview !== "" ? ( - <> - Public page: {publicOrgUrl(slugPreview)} - - ) : ( - "3–40 lowercase letters, digits and hyphens." - )} - - )} - {mode === "edit" && ( - - Changing the slug breaks existing links to the public - page, QR codes and shared signup pages. - - )} -
+