From 5981e357a2776389c397d8998232fdd3631ea732 Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 21:44:09 -0400 Subject: [PATCH 1/5] pin shared snapshot --- apps/admin/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/admin/package.json b/apps/admin/package.json index 3eb9626..a24094a 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -15,7 +15,7 @@ "clean": "rimraf .next out *.tsbuildinfo" }, "dependencies": { - "@civfix/shared": "^0.57.0", + "@civfix/shared": "0.58.0-b-feat-admin-user-hours-contract.g47d8a38", "@tanstack/react-query": "^5.62.7", "clsx": "^2.1.1", "leaflet": "^1.9.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c961fb..c641e6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: apps/admin: dependencies: '@civfix/shared': - specifier: ^0.57.0 - version: 0.57.0 + specifier: 0.58.0-b-feat-admin-user-hours-contract.g47d8a38 + version: 0.58.0-b-feat-admin-user-hours-contract.g47d8a38 '@tanstack/react-query': specifier: ^5.62.7 version: 5.101.0(react@19.0.0) @@ -101,8 +101,8 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@civfix/shared@0.57.0': - resolution: {integrity: sha512-v4IFb4pR9hbnf+AueRmL7GLB8DIhG9c9ZJjAnXKK3Yqjsv0Mbmhj0Khmb27YRgKL1Aoe//7nLiFkgj11Qrg7GQ==} + '@civfix/shared@0.58.0-b-feat-admin-user-hours-contract.g47d8a38': + resolution: {integrity: sha512-J2l/KZ19Nn98wV8iS1EF3lUmCUUTdElMxnRH3bO+ij/RhGMo02O35u22Ly8nvt7rMC0qf4DNH2BZo9z23l5tLQ==} engines: {node: '>=20'} '@emnapi/core@1.10.0': @@ -2484,7 +2484,7 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@civfix/shared@0.57.0': + '@civfix/shared@0.58.0-b-feat-admin-user-hours-contract.g47d8a38': dependencies: zod: 3.25.76 From fc8d570eb7ff6b4b7c930bb45a1cb2e36dae0ab2 Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 21:49:36 -0400 Subject: [PATCH 2/5] user hours tab with ledger and void --- .../src/components/shared/dialog.test.ts | 22 ++ apps/admin/src/components/shared/dialog.tsx | 23 +- .../src/features/users/use-user-hours.test.ts | 114 +++++++++ .../src/features/users/use-user-hours.ts | 46 ++++ .../src/features/users/user-hours-panel.tsx | 220 ++++++++++++++++++ .../src/features/users/user-hours.test.ts | 121 ++++++++++ apps/admin/src/features/users/user-hours.ts | 69 ++++++ apps/admin/src/features/users/users-page.tsx | 24 +- apps/admin/src/lib/query.ts | 1 + apps/admin/src/styles/app.css | 26 +++ 10 files changed, 655 insertions(+), 11 deletions(-) create mode 100644 apps/admin/src/components/shared/dialog.test.ts create mode 100644 apps/admin/src/features/users/use-user-hours.test.ts create mode 100644 apps/admin/src/features/users/use-user-hours.ts create mode 100644 apps/admin/src/features/users/user-hours-panel.tsx create mode 100644 apps/admin/src/features/users/user-hours.test.ts create mode 100644 apps/admin/src/features/users/user-hours.ts diff --git a/apps/admin/src/components/shared/dialog.test.ts b/apps/admin/src/components/shared/dialog.test.ts new file mode 100644 index 0000000..253305b --- /dev/null +++ b/apps/admin/src/components/shared/dialog.test.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs" + +import { describe, expect, it } from "vitest" + +const source = readFileSync(new URL("./dialog.tsx", import.meta.url), "utf8") + +describe("dialog host", () => { + it("bounds the prompt textarea by the request's maxLength", () => { + const textarea = source.match(//)?.[0] ?? "" + expect(textarea).toContain("maxLength={current.maxLength}") + }) + + it("drops the cancel button only for an acknowledge-only confirm", () => { + expect(source).toMatch( + /\{!\(current\.kind === "confirm" && current\.acknowledgeOnly\) && \(\s* + {!(current.kind === "confirm" && current.acknowledgeOnly) && ( + + )} + ) : ( +
{content}
+ )} + {formatHours(entry.hours)} h + {entry.voidable && ( + + )} + + ) +} + +export function UserHoursPanel({ user }: { user: AdminUserDTO }) { + const q = useUserHours(user.id) + const voidHours = useVoidUserHours() + const toast = useToast() + // Guards a second click while the reason prompt is already open. + const prompting = React.useRef(false) + + const onVoid = async (entry: AdminUserHoursEntryDTO) => { + if (prompting.current || voidHours.isPending) return + prompting.current = true + let reason: string | null + try { + reason = await promptDialog({ + title: `Void ${formatHours(entry.hours)} h — ${hoursEntryTitle(entry)}`, + body: "The entry stops counting toward this neighbor's total and stays on this ledger marked Voided. The neighbor is not notified.", + label: "Reason (required)", + required: true, + danger: true, + confirmLabel: "Void entry", + maxLength: VOID_REASON_MAX, + }) + } finally { + prompting.current = false + } + if (reason === null) return + voidHours.mutate( + { id: user.id, entryId: entry.id, reason: reason.trim(), eventId: entry.event?.id ?? null }, + { + onSuccess: (res) => { + toast(voidedToast(user.name, entry.hours)) + if (res.affectedCertificates.length === 0) return + void confirmDialog({ + title: "Issued transcripts still list this entry", + body: `These transcripts still verify with the old total until they are revoked. Revoke each one from the backend with: ${REVOKE_CERTIFICATE_COMMAND}`, + details: res.affectedCertificates.map(affectedCertificateLine), + confirmLabel: "Done", + acknowledgeOnly: true, + }) + }, + }, + ) + } + + if (q.isLoading) { + return ( +
+ +
+ ) + } + if (q.isError) { + return ( +
+ q.refetch()} /> +
+ ) + } + + const pages = q.data?.pages ?? [] + const items = pages.flatMap((p) => p.items) + const totals = pages[0]?.totals + + return ( + <> + {totals && ( +
+
+ {formatHours(totals.totalHours)} + Total hours +
+
+ {totals.liveEntries.toLocaleString()} + Entries +
+
+ {totals.voidedEntries.toLocaleString()} + Voided +
+
+ )} +
+ {items.length === 0 ? ( + } + /> + ) : ( + <> + {items.map((entry) => ( + void onVoid(e)} + voiding={voidHours.isPending} + /> + ))} + {q.hasNextPage && ( + + )} + + )} +
+ + ) +} diff --git a/apps/admin/src/features/users/user-hours.test.ts b/apps/admin/src/features/users/user-hours.test.ts new file mode 100644 index 0000000..48cee85 --- /dev/null +++ b/apps/admin/src/features/users/user-hours.test.ts @@ -0,0 +1,121 @@ +import type { AdminUserHoursEntryDTO } from "@civfix/shared" +import { describe, expect, it } from "vitest" + +import { formatDate } from "@/lib/dates" + +import { + affectedCertificateLine, + formatHours, + formatServiceDate, + hoursEntryCreditor, + hoursEntryDate, + hoursEntryMeta, + hoursEntryTitle, + hoursEntryVoidDetail, + voidedToast, +} from "./user-hours" + +const USER_ID = "00000000-0000-4000-8000-000000000001" +const OFFICIAL_ID = "00000000-0000-4000-8000-0000000000c1" + +function entry(over: Partial = {}): AdminUserHoursEntryDTO { + return { + id: "00000000-0000-4000-8000-0000000000e1", + source: "event", + hours: 2.5, + occurredAt: "2026-09-12T17:00:00.000Z", + creditedAt: "2026-09-13T09:00:00.000Z", + serviceDate: null, + event: { + id: "00000000-0000-4000-8000-0000000000a1", + title: "Echo Park Lake cleanup", + referenceCode: null, + }, + jurisdiction: { geoid: "0644000", name: "Los Angeles" }, + creditedBy: { id: USER_ID, name: "Maya Host", handle: "@maya", official: false }, + operator: null, + note: null, + voidedAt: null, + voidedBy: null, + voidReason: null, + voidable: true, + ...over, + } +} + +const manual = (over: Partial = {}) => + entry({ + source: "manual", + serviceDate: "2026-09-20", + occurredAt: "2026-09-20T12:00:00.000Z", + event: null, + jurisdiction: null, + creditedBy: { id: OFFICIAL_ID, name: "CivFix", handle: "@civfix", official: true }, + operator: { id: USER_ID, name: "Ops Olu" }, + note: "Staffed the sign-in table", + ...over, + }) + +describe("hours ledger labels", () => { + it("titles each source", () => { + expect(hoursEntryTitle(entry())).toBe("Echo Park Lake cleanup") + expect(hoursEntryTitle(entry({ event: null }))).toBe("Event credit") + expect(hoursEntryTitle(manual())).toBe("Manual adjustment") + expect(hoursEntryTitle(entry({ source: "report", event: null }))).toBe("Report credit (retired)") + }) + + it("names the host, or CivFix with the operator behind an official credit", () => { + expect(hoursEntryCreditor(entry())).toBe("Credited by Maya Host") + expect(hoursEntryCreditor(manual())).toBe("Credited by CivFix · Ops Olu") + expect(hoursEntryCreditor(manual({ operator: null }))).toBe("Credited by CivFix") + expect(hoursEntryCreditor(entry({ creditedBy: null }))).toBeNull() + }) + + it("dates a manual row by its service day, never shifted by the viewer's zone", () => { + const local = new Date(2026, 8, 20).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }) + expect(formatServiceDate("2026-09-20")).toBe(local) + expect(hoursEntryDate(manual({ occurredAt: "2026-01-01T00:00:00.000Z" }))).toBe(local) + expect(hoursEntryDate(entry())).toBe(formatDate("2026-09-12T17:00:00.000Z")) + }) + + it("joins date, jurisdiction and creditor, skipping what the row lacks", () => { + expect(hoursEntryMeta(entry())).toEqual([ + formatDate("2026-09-12T17:00:00.000Z"), + "Los Angeles", + "Credited by Maya Host", + ]) + expect(hoursEntryMeta(entry({ jurisdiction: { geoid: "0644000", name: null } }))[1]).toBe("0644000") + expect(hoursEntryMeta(manual())).toEqual([formatServiceDate("2026-09-20"), "Credited by CivFix · Ops Olu"]) + }) + + it("describes a void by whoever is known", () => { + const voided = { voidedAt: "2026-09-21T10:00:00.000Z", voidable: false } + expect(hoursEntryVoidDetail(entry())).toBeNull() + expect( + hoursEntryVoidDetail( + entry({ ...voided, voidedBy: { id: USER_ID, name: "Ops Olu" }, voidReason: "Wrong person" }), + ), + ).toBe("by Ops Olu: Wrong person") + expect(hoursEntryVoidDetail(entry({ ...voided, voidedBy: { id: USER_ID, name: "Ops Olu" } }))).toBe( + "by Ops Olu", + ) + expect(hoursEntryVoidDetail(entry({ ...voided, source: "report" }))).toBeNull() + }) + + it("formats hours to at most two decimals", () => { + expect(formatHours(3)).toBe("3") + expect(formatHours(2.5)).toBe("2.5") + expect(formatHours(1.256)).toBe("1.26") + expect(voidedToast("Rosa", 2.5)).toBe("Rosa · 2.5 h voided") + }) + + it("prints an affected certificate in its display form", () => { + expect(affectedCertificateLine({ code: "A1B2C3D4E5F6", issuedAt: "2026-09-01T12:00:00.000Z" })).toBe( + `CFX-A1B2-C3D4-E5F6 · issued ${formatDate("2026-09-01T12:00:00.000Z")}`, + ) + }) +}) diff --git a/apps/admin/src/features/users/user-hours.ts b/apps/admin/src/features/users/user-hours.ts new file mode 100644 index 0000000..923399e --- /dev/null +++ b/apps/admin/src/features/users/user-hours.ts @@ -0,0 +1,69 @@ +import { + formatCertificateCode, + type AdminUserHoursEntryDTO, + type AdminVoidUserHoursResponse, +} from "@civfix/shared" + +import { formatDate } from "@/lib/dates" + +type AffectedCertificate = AdminVoidUserHoursResponse["affectedCertificates"][number] + +/** + * The backend's operator CLI (`services/api/scripts/revoke-certificate.ts`). The console never revokes a + * certificate itself (DECISIONS §58), so the void dialog names the command instead. + */ +export const REVOKE_CERTIFICATE_COMMAND = + "pnpm --filter @civfix/api db:certificate:revoke --reason ledger_corrected" + +export function formatHours(hours: number): string { + return hours.toLocaleString(undefined, { maximumFractionDigits: 2 }) +} + +export function hoursEntryTitle(entry: AdminUserHoursEntryDTO): string { + if (entry.source === "manual") return "Manual adjustment" + if (entry.source === "report") return "Report credit (retired)" + return entry.event?.title ?? "Event credit" +} + +/** + * A service date is a calendar day with no zone. Read as local noon, because `new Date("YYYY-MM-DD")` + * is UTC midnight and would print the previous day west of Greenwich. + */ +export function formatServiceDate(serviceDate: string): string { + return formatDate(`${serviceDate}T12:00:00`) +} + +export function hoursEntryDate(entry: AdminUserHoursEntryDTO): string { + if (entry.source === "manual" && entry.serviceDate) return formatServiceDate(entry.serviceDate) + return formatDate(entry.occurredAt) +} + +export function hoursEntryCreditor(entry: AdminUserHoursEntryDTO): string | null { + const { creditedBy, operator } = entry + if (!creditedBy) return null + if (!creditedBy.official) return `Credited by ${creditedBy.name}` + return operator ? `Credited by CivFix · ${operator.name}` : "Credited by CivFix" +} + +export function hoursEntryMeta(entry: AdminUserHoursEntryDTO): string[] { + const jurisdiction = entry.jurisdiction ? (entry.jurisdiction.name ?? entry.jurisdiction.geoid) : null + return [hoursEntryDate(entry), jurisdiction, hoursEntryCreditor(entry)].filter( + (part): part is string => part !== null, + ) +} + +/** Rows voided by a migration (the retired report credits) carry neither an operator nor a reason. */ +export function hoursEntryVoidDetail(entry: AdminUserHoursEntryDTO): string | null { + if (!entry.voidedAt) return null + if (entry.voidedBy && entry.voidReason) return `by ${entry.voidedBy.name}: ${entry.voidReason}` + if (entry.voidedBy) return `by ${entry.voidedBy.name}` + return entry.voidReason +} + +export function voidedToast(userName: string, hours: number): string { + return `${userName} · ${formatHours(hours)} h voided` +} + +export function affectedCertificateLine(certificate: AffectedCertificate): string { + return `${formatCertificateCode(certificate.code)} · issued ${formatDate(certificate.issuedAt)}` +} diff --git a/apps/admin/src/features/users/users-page.tsx b/apps/admin/src/features/users/users-page.tsx index ebfc5d3..37fd002 100644 --- a/apps/admin/src/features/users/users-page.tsx +++ b/apps/admin/src/features/users/users-page.tsx @@ -41,6 +41,7 @@ import { userOrganizationsView, type UserOrganization, } from "@/features/users/user-organizations" +import { UserHoursPanel } from "@/features/users/user-hours-panel" import { getUserMessageDestination } from "./profile-activity-navigation" import { useNav, useToast, type PageId } from "@/store/ui-store" import type { SectionPageProps } from "@/components/shell/page-registry" @@ -218,9 +219,10 @@ function ProfileMessageRow({ ) } -type TabId = "reports" | "events" | "messages" +type TabId = "reports" | "events" | "messages" | "hours" +type ActivityTabId = Exclude -function UserActivity({ userId, tab }: { userId: string; tab: TabId }) { +function UserActivity({ userId, tab }: { userId: string; tab: ActivityTabId }) { const nav = useNav() const reports = useUserReports(tab === "reports" ? userId : null) const events = useUserEvents(tab === "events" ? userId : null) @@ -353,10 +355,11 @@ function UserOrganizationRow({ org, nav }: { org: UserOrganization; nav: NavFn } ) } -function tabCount(user: AdminUserDTO, id: TabId): number { +function tabCount(user: AdminUserDTO, id: TabId): number | null { if (id === "reports") return user.reports if (id === "events") return user.cleanups - return user.messages + if (id === "messages") return user.messages + return null } function UserDetail({ userId }: { userId: string }) { @@ -389,6 +392,7 @@ function UserDetail({ userId }: { userId: string }) { { id: "reports", label: "Reports" }, { id: "events", label: "Events" }, { id: "messages", label: "Messages" }, + { id: "hours", label: "Hours" }, ] const onFlag = () => { @@ -552,15 +556,19 @@ function UserDetail({ userId }: { userId: string }) { onClick={() => setTab(t.id)} > {t.label} - {n} + {n !== null && {n}} ) })} -
- -
+ {tab === "hours" ? ( + + ) : ( +
+ +
+ )}
{deleted && ( diff --git a/apps/admin/src/lib/query.ts b/apps/admin/src/lib/query.ts index b864508..15443fa 100644 --- a/apps/admin/src/lib/query.ts +++ b/apps/admin/src/lib/query.ts @@ -86,6 +86,7 @@ export const queryKeys = { events: (id: string, params?: unknown) => ["admin", "users", id, "events", params ?? null] as const, messages: (id: string, params?: unknown) => ["admin", "users", id, "messages", params ?? null] as const, + hours: (id: string, params?: unknown) => ["admin", "users", id, "hours", params ?? null] as const, }, mail: { diff --git a/apps/admin/src/styles/app.css b/apps/admin/src/styles/app.css index ee278e6..eb39419 100644 --- a/apps/admin/src/styles/app.css +++ b/apps/admin/src/styles/app.css @@ -587,6 +587,32 @@ img.org-logo { object-fit: cover; background: var(--paper-2); } } .user-org-row .title.lnk-inline { cursor: pointer; } +/* Users page, Hours tab: the ledger totals strip under the tabs, and one .prow per ledger entry. */ +.user-detail .user-hours-stats { margin-top: var(--space-3); margin-bottom: 0; } +.prow-hours { + flex-shrink: 0; + font-family: var(--font-mono); + font-size: var(--fs-13); + font-weight: 700; + color: var(--ink); + font-feature-settings: "tnum" 1; + white-space: nowrap; +} +.prow-meta.prow-note { white-space: normal; font-style: italic; } +.prow.removed .prow-main { opacity: 0.6; } +.prow.removed .prow-hours { text-decoration: line-through; } + +/* A confirm dialog's itemised details (certificate codes, ...). */ +.dialog-list { + margin: 0; + padding-left: var(--space-5); + display: flex; + flex-direction: column; + gap: var(--space-1); + font-size: var(--fs-12); + color: var(--ink-2); +} + /* Member row actions menu. */ .member-row .title.lnk-inline { cursor: pointer; } .btn.icon { width: 28px; padding: 0; justify-content: center; } From cbd71f409647b54c6e5a7875d417e829b203f807 Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 21:55:16 -0400 Subject: [PATCH 3/5] run void follow-up from the mutation --- apps/admin/src/components/shared/dialog.tsx | 2 - .../src/features/users/use-user-hours.test.ts | 69 +++++++++++++++++++ .../src/features/users/use-user-hours.ts | 31 ++++++++- .../src/features/users/user-hours-panel.tsx | 32 +++------ apps/admin/src/styles/app.css | 1 - 5 files changed, 107 insertions(+), 28 deletions(-) diff --git a/apps/admin/src/components/shared/dialog.tsx b/apps/admin/src/components/shared/dialog.tsx index d1d53fa..1719ad3 100644 --- a/apps/admin/src/components/shared/dialog.tsx +++ b/apps/admin/src/components/shared/dialog.tsx @@ -13,9 +13,7 @@ interface ConfirmRequest { confirmLabel?: string cancelLabel?: string danger?: boolean - /** Rendered as a monospace list under the body, one line per item. */ details?: string[] - /** A notice with nothing to decline: only the confirm button renders. */ acknowledgeOnly?: boolean resolve: (ok: boolean) => void } diff --git a/apps/admin/src/features/users/use-user-hours.test.ts b/apps/admin/src/features/users/use-user-hours.test.ts index e71b2e8..9ea4a8a 100644 --- a/apps/admin/src/features/users/use-user-hours.test.ts +++ b/apps/admin/src/features/users/use-user-hours.test.ts @@ -3,17 +3,22 @@ import { readFileSync } from "node:fs" import { MutationObserver, QueryClient } from "@tanstack/react-query" import { beforeEach, describe, expect, it, vi } from "vitest" +import { confirmDialog } from "@/components/shared/dialog" import type * as apiModule from "@/lib/api" import { api } from "@/lib/api" import { queryKeys } from "@/lib/query" +import { useUiStore } from "@/store/ui-store" import { voidUserHoursOptions } from "./use-user-hours" +import { REVOKE_CERTIFICATE_COMMAND, affectedCertificateLine } from "./user-hours" vi.mock("@/lib/api", async (importOriginal) => ({ ...(await importOriginal()), api: { voidUserHours: vi.fn() }, })) +vi.mock("@/components/shared/dialog", () => ({ confirmDialog: vi.fn() })) + const USER = "00000000-0000-4000-8000-000000000001" const OTHER_USER = "00000000-0000-4000-8000-000000000002" const ENTRY = "00000000-0000-4000-8000-0000000000e1" @@ -40,6 +45,8 @@ function seed(qc: QueryClient) { describe("void user hours", () => { beforeEach(() => { vi.mocked(api.voidUserHours).mockReset() + vi.mocked(confirmDialog).mockReset().mockResolvedValue(true) + useUiStore.setState({ toast: null }) }) it("sends only the contract fields and refreshes the ledger, profile, audit log and event", async () => { @@ -52,6 +59,8 @@ describe("void user hours", () => { entryId: ENTRY, reason: "Credited the wrong person", eventId: EVENT, + userName: "Rosa", + hours: 2.5, }) expect(res).toEqual({ ok: true, affectedCertificates: [] }) @@ -74,6 +83,8 @@ describe("void user hours", () => { entryId: ENTRY, reason: "Duplicate adjustment", eventId: null, + userName: "Rosa", + hours: 1, }) expect(qc.getQueryState(queryKeys.users.hours(USER))?.isInvalidated).toBe(true) @@ -91,9 +102,67 @@ describe("void user hours", () => { entryId: ENTRY, reason: "Already void", eventId: EVENT, + userName: "Rosa", + hours: 2, }), ).rejects.toThrow("conflict") for (const key of touched) expect(qc.getQueryState(key)?.isInvalidated).toBe(false) + expect(useUiStore.getState().toast).toBeNull() + expect(confirmDialog).not.toHaveBeenCalled() + }) + + it("toasts the void and skips the certificate notice when no transcript lists the entry", async () => { + vi.mocked(api.voidUserHours).mockResolvedValue({ ok: true, affectedCertificates: [] }) + const qc = new QueryClient() + + await new MutationObserver(qc, voidUserHoursOptions(qc)).mutate({ + id: USER, + entryId: ENTRY, + reason: "Duplicate adjustment", + eventId: null, + userName: "Rosa", + hours: 2.5, + }) + + expect(useUiStore.getState().toast?.text).toBe("Rosa · 2.5 h voided") + expect(confirmDialog).not.toHaveBeenCalled() + }) + + it("names the affected transcripts even when no panel is still subscribed to the void", async () => { + vi.mocked(api.voidUserHours).mockResolvedValue({ + ok: true, + affectedCertificates: [ + { code: "A1B2C3D4E5F6", issuedAt: "2026-03-14T18:00:00.000Z" }, + { code: "B2C3D4E5F6A1", issuedAt: "2026-05-02T18:00:00.000Z" }, + ], + }) + const qc = new QueryClient() + const observer = new MutationObserver(qc, voidUserHoursOptions(qc)) + const perCallOnSuccess = vi.fn() + + await observer.mutate( + { + id: USER, + entryId: ENTRY, + reason: "Credited the wrong person", + eventId: EVENT, + userName: "Rosa", + hours: 3, + }, + { onSuccess: perCallOnSuccess }, + ) + + expect(perCallOnSuccess).not.toHaveBeenCalled() + expect(useUiStore.getState().toast?.text).toBe("Rosa · 3 h voided") + expect(confirmDialog).toHaveBeenCalledTimes(1) + const request = vi.mocked(confirmDialog).mock.calls[0]![0] + expect(request.acknowledgeOnly).toBe(true) + expect(request.danger).toBeUndefined() + expect(request.body).toContain(REVOKE_CERTIFICATE_COMMAND) + expect(request.details).toEqual([ + affectedCertificateLine({ code: "A1B2C3D4E5F6", issuedAt: "2026-03-14T18:00:00.000Z" }), + affectedCertificateLine({ code: "B2C3D4E5F6A1", issuedAt: "2026-05-02T18:00:00.000Z" }), + ]) }) it("asks for a required, bounded reason before voiding, one prompt at a time", () => { diff --git a/apps/admin/src/features/users/use-user-hours.ts b/apps/admin/src/features/users/use-user-hours.ts index bb835b3..b8ba8d5 100644 --- a/apps/admin/src/features/users/use-user-hours.ts +++ b/apps/admin/src/features/users/use-user-hours.ts @@ -8,8 +8,15 @@ import { } from "@tanstack/react-query" import type { AdminUserHoursResponse, AdminVoidUserHoursRequest } from "@civfix/shared" +import { confirmDialog } from "@/components/shared/dialog" import { api } from "@/lib/api" import { queryKeys } from "@/lib/query" +import { useUiStore } from "@/store/ui-store" +import { + REVOKE_CERTIFICATE_COMMAND, + affectedCertificateLine, + voidedToast, +} from "@/features/users/user-hours" export function useUserHours(id: string) { return useInfiniteQuery({ @@ -24,18 +31,36 @@ export function useUserHours(id: string) { }) } -/** `eventId` is the voided row's event, whose hours block must refetch; it never reaches the API. */ -export type VoidUserHoursInput = AdminVoidUserHoursRequest & { eventId: string | null } +/** + * The extra fields never reach the API. `eventId` is the voided row's event, whose hours block must + * refetch; `userName` and `hours` word the follow-up, which runs here rather than in the panel so it + * survives the panel unmounting mid-request: the affected certificate codes exist only in this response, + * and a second void of the same entry is a CONFLICT. + */ +export type VoidUserHoursInput = AdminVoidUserHoursRequest & { + eventId: string | null + userName: string + hours: number +} export function voidUserHoursOptions(qc: ReturnType) { return mutationOptions({ mutationFn: ({ id, entryId, reason }: VoidUserHoursInput) => api.voidUserHours({ id, entryId, reason }), - onSuccess: (_res, { id, eventId }) => { + onSuccess: (res, { id, eventId, userName, hours }) => { qc.invalidateQueries({ queryKey: queryKeys.users.hours(id) }) qc.invalidateQueries({ queryKey: queryKeys.users.detail(id) }) qc.invalidateQueries({ queryKey: queryKeys.audit.all }) if (eventId) qc.invalidateQueries({ queryKey: queryKeys.events.detail(eventId) }) + useUiStore.getState().showToast(voidedToast(userName, hours)) + if (res.affectedCertificates.length === 0) return + void confirmDialog({ + title: "Issued transcripts still list this entry", + body: `These transcripts still verify with the old total until they are revoked. Revoke each one from the backend with: ${REVOKE_CERTIFICATE_COMMAND}`, + details: res.affectedCertificates.map(affectedCertificateLine), + confirmLabel: "Done", + acknowledgeOnly: true, + }) }, }) } diff --git a/apps/admin/src/features/users/user-hours-panel.tsx b/apps/admin/src/features/users/user-hours-panel.tsx index b3a043b..761c244 100644 --- a/apps/admin/src/features/users/user-hours-panel.tsx +++ b/apps/admin/src/features/users/user-hours-panel.tsx @@ -6,18 +6,15 @@ import { type AdminUserDTO, type AdminUserHoursEntryDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" -import { confirmDialog, promptDialog } from "@/components/shared/dialog" +import { promptDialog } from "@/components/shared/dialog" import { formatDateTime } from "@/lib/dates" -import { useNav, useToast } from "@/store/ui-store" +import { useNav } from "@/store/ui-store" import { useUserHours, useVoidUserHours } from "@/features/users/use-user-hours" import { - REVOKE_CERTIFICATE_COMMAND, - affectedCertificateLine, formatHours, hoursEntryMeta, hoursEntryTitle, hoursEntryVoidDetail, - voidedToast, } from "@/features/users/user-hours" const VOID_REASON_MAX = 1000 @@ -108,7 +105,6 @@ function HoursEntryRow({ export function UserHoursPanel({ user }: { user: AdminUserDTO }) { const q = useUserHours(user.id) const voidHours = useVoidUserHours() - const toast = useToast() // Guards a second click while the reason prompt is already open. const prompting = React.useRef(false) @@ -130,22 +126,14 @@ export function UserHoursPanel({ user }: { user: AdminUserDTO }) { prompting.current = false } if (reason === null) return - voidHours.mutate( - { id: user.id, entryId: entry.id, reason: reason.trim(), eventId: entry.event?.id ?? null }, - { - onSuccess: (res) => { - toast(voidedToast(user.name, entry.hours)) - if (res.affectedCertificates.length === 0) return - void confirmDialog({ - title: "Issued transcripts still list this entry", - body: `These transcripts still verify with the old total until they are revoked. Revoke each one from the backend with: ${REVOKE_CERTIFICATE_COMMAND}`, - details: res.affectedCertificates.map(affectedCertificateLine), - confirmLabel: "Done", - acknowledgeOnly: true, - }) - }, - }, - ) + voidHours.mutate({ + id: user.id, + entryId: entry.id, + reason: reason.trim(), + eventId: entry.event?.id ?? null, + userName: user.name, + hours: entry.hours, + }) } if (q.isLoading) { diff --git a/apps/admin/src/styles/app.css b/apps/admin/src/styles/app.css index eb39419..bba1f98 100644 --- a/apps/admin/src/styles/app.css +++ b/apps/admin/src/styles/app.css @@ -602,7 +602,6 @@ img.org-logo { object-fit: cover; background: var(--paper-2); } .prow.removed .prow-main { opacity: 0.6; } .prow.removed .prow-hours { text-decoration: line-through; } -/* A confirm dialog's itemised details (certificate codes, ...). */ .dialog-list { margin: 0; padding-left: var(--space-5); From 86c6602d4be7d11d9da3b7c2868d2d06037de30c Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 26 Sep 2026 22:26:52 -0400 Subject: [PATCH 4/5] scope voided ledger dimming to hours rows --- apps/admin/src/features/users/user-hours-panel.tsx | 2 +- apps/admin/src/styles/app.css | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/admin/src/features/users/user-hours-panel.tsx b/apps/admin/src/features/users/user-hours-panel.tsx index 761c244..0d93bff 100644 --- a/apps/admin/src/features/users/user-hours-panel.tsx +++ b/apps/admin/src/features/users/user-hours-panel.tsx @@ -73,7 +73,7 @@ function HoursEntryRow({ ) return ( -
+
{event ? ( +
+ ) + } + + return ( +
+
+ + setQuery(e.target.value)} + /> +
+ {list.isLoading ? ( +
+ +
+ ) : list.isError ? ( +
+
{toAppError(list.error).message}
+
+ ) : results.length === 0 ? ( +
+
+ {q === "" ? "No ended events yet." : `No ended event matches “${q}”.`} +
+
+ ) : ( +
    + {results.map((event) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} diff --git a/apps/admin/src/features/users/use-user-hours.test.ts b/apps/admin/src/features/users/use-user-hours.test.ts index 9ea4a8a..e4e3546 100644 --- a/apps/admin/src/features/users/use-user-hours.test.ts +++ b/apps/admin/src/features/users/use-user-hours.test.ts @@ -9,12 +9,12 @@ import { api } from "@/lib/api" import { queryKeys } from "@/lib/query" import { useUiStore } from "@/store/ui-store" -import { voidUserHoursOptions } from "./use-user-hours" +import { creditUserHoursOptions, voidUserHoursOptions } from "./use-user-hours" import { REVOKE_CERTIFICATE_COMMAND, affectedCertificateLine } from "./user-hours" vi.mock("@/lib/api", async (importOriginal) => ({ ...(await importOriginal()), - api: { voidUserHours: vi.fn() }, + api: { voidUserHours: vi.fn(), creditUserHours: vi.fn() }, })) vi.mock("@/components/shared/dialog", () => ({ confirmDialog: vi.fn() })) @@ -181,3 +181,106 @@ describe("void user hours", () => { expect(source).toContain("const VOID_REASON_MAX = 1000") }) }) + +describe("credit user hours", () => { + const eventRequest = { + id: USER, + kind: "event" as const, + eventId: EVENT, + hours: 3.5, + reason: "Attended before signing up", + } + const manualRequest = { + id: USER, + kind: "manual" as const, + hours: 1.25, + serviceDate: "2026-09-20", + reason: "Staffed the sign-in table", + } + + beforeEach(() => { + vi.mocked(api.creditUserHours).mockReset() + useUiStore.setState({ toast: null }) + }) + + it("sends only the request and refreshes the ledger, profile, audit log and event", async () => { + vi.mocked(api.creditUserHours).mockResolvedValue({ entryId: ENTRY, totalHours: 6 }) + const qc = new QueryClient() + const { touched, untouched } = seed(qc) + + const res = await new MutationObserver(qc, creditUserHoursOptions(qc)).mutate({ + request: eventRequest, + userName: "Rosa", + showsErrorInline: () => true, + }) + + expect(res).toEqual({ entryId: ENTRY, totalHours: 6 }) + expect(api.creditUserHours).toHaveBeenCalledWith(eventRequest) + for (const key of touched) expect(qc.getQueryState(key)?.isInvalidated).toBe(true) + for (const key of untouched) expect(qc.getQueryState(key)?.isInvalidated).toBe(false) + expect(useUiStore.getState().toast?.text).toBe("Rosa · +3.5 h") + }) + + it("leaves every event alone for a manual adjustment", async () => { + vi.mocked(api.creditUserHours).mockResolvedValue({ entryId: ENTRY, totalHours: 1.25 }) + const qc = new QueryClient() + seed(qc) + + await new MutationObserver(qc, creditUserHoursOptions(qc)).mutate({ + request: manualRequest, + userName: "Rosa", + showsErrorInline: () => true, + }) + + expect(api.creditUserHours).toHaveBeenCalledWith(manualRequest) + expect(qc.getQueryState(queryKeys.users.hours(USER))?.isInvalidated).toBe(true) + expect(qc.getQueryState(queryKeys.events.detail(EVENT))?.isInvalidated).toBe(false) + expect(useUiStore.getState().toast?.text).toBe("Rosa · +1.25 h") + }) + + it("stays quiet on failure while the form can show the error, invalidating nothing", async () => { + vi.mocked(api.creditUserHours).mockRejectedValue(new Error("already credited")) + const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + const { touched } = seed(qc) + + await expect( + new MutationObserver(qc, creditUserHoursOptions(qc)).mutate({ + request: eventRequest, + userName: "Rosa", + showsErrorInline: () => true, + }), + ).rejects.toThrow("already credited") + for (const key of touched) expect(qc.getQueryState(key)?.isInvalidated).toBe(false) + expect(useUiStore.getState().toast).toBeNull() + }) + + it("toasts the failure once the form that sent it is gone", async () => { + vi.mocked(api.creditUserHours).mockRejectedValue(new Error("already credited")) + const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + + await expect( + new MutationObserver(qc, creditUserHoursOptions(qc)).mutate({ + request: eventRequest, + userName: "Rosa", + showsErrorInline: () => false, + }), + ).rejects.toThrow("already credited") + expect(useUiStore.getState().toast?.text).toBe("already credited") + }) + + it("confirms a valid draft before crediting, one confirmation at a time", () => { + const source = readFileSync(new URL("./user-hours-panel.tsx", import.meta.url), "utf8") + const guard = source.indexOf("if (confirming.current || credit.isPending) return") + const build = source.indexOf("const request = buildCreditRequest(") + const invalid = source.indexOf("if (!request) return") + const confirm = source.indexOf("ok = await confirmDialog(") + const cancelled = source.indexOf("if (!ok) return") + const mutate = source.indexOf("credit.mutate(") + expect(guard).toBeGreaterThan(-1) + expect(build).toBeGreaterThan(guard) + expect(invalid).toBeGreaterThan(build) + expect(confirm).toBeGreaterThan(invalid) + expect(cancelled).toBeGreaterThan(confirm) + expect(mutate).toBeGreaterThan(cancelled) + }) +}) diff --git a/apps/admin/src/features/users/use-user-hours.ts b/apps/admin/src/features/users/use-user-hours.ts index b8ba8d5..87175a2 100644 --- a/apps/admin/src/features/users/use-user-hours.ts +++ b/apps/admin/src/features/users/use-user-hours.ts @@ -6,15 +6,21 @@ import { useMutation, useQueryClient, } from "@tanstack/react-query" -import type { AdminUserHoursResponse, AdminVoidUserHoursRequest } from "@civfix/shared" +import type { + AdminCreditUserHoursRequest, + AdminUserHoursResponse, + AdminVoidUserHoursRequest, +} from "@civfix/shared" import { confirmDialog } from "@/components/shared/dialog" import { api } from "@/lib/api" +import { errorMessage } from "@/lib/error-messages" import { queryKeys } from "@/lib/query" import { useUiStore } from "@/store/ui-store" import { REVOKE_CERTIFICATE_COMMAND, affectedCertificateLine, + creditedToast, voidedToast, } from "@/features/users/user-hours" @@ -31,6 +37,48 @@ export function useUserHours(id: string) { }) } +function invalidateLedger( + qc: ReturnType, + userId: string, + eventId: string | null, +) { + qc.invalidateQueries({ queryKey: queryKeys.users.hours(userId) }) + qc.invalidateQueries({ queryKey: queryKeys.users.detail(userId) }) + qc.invalidateQueries({ queryKey: queryKeys.audit.all }) + if (eventId) qc.invalidateQueries({ queryKey: queryKeys.events.detail(eventId) }) +} + +/** + * Only `request` reaches the API. The toast runs here rather than in the form so a credit that lands + * after the form unmounted is still announced. `showsErrorInline` reports whether the form that sent + * the credit is still mounted to render a failure next to its fields; when it is not, the failure + * falls back to the toast instead of vanishing. + */ +export interface CreditUserHoursInput { + request: AdminCreditUserHoursRequest + userName: string + showsErrorInline: () => boolean +} + +export function creditUserHoursOptions(qc: ReturnType) { + return mutationOptions({ + mutationFn: ({ request }: CreditUserHoursInput) => api.creditUserHours(request), + onSuccess: (_res, { request, userName }) => { + invalidateLedger(qc, request.id, request.kind === "event" ? request.eventId : null) + useUiStore.getState().showToast(creditedToast(userName, request.hours)) + }, + onError: (err, { showsErrorInline }) => { + if (showsErrorInline()) return + useUiStore.getState().showToast(errorMessage(err)) + }, + }) +} + +export function useCreditUserHours() { + const qc = useQueryClient() + return useMutation(creditUserHoursOptions(qc)) +} + /** * The extra fields never reach the API. `eventId` is the voided row's event, whose hours block must * refetch; `userName` and `hours` word the follow-up, which runs here rather than in the panel so it @@ -48,10 +96,7 @@ export function voidUserHoursOptions(qc: ReturnType) { mutationFn: ({ id, entryId, reason }: VoidUserHoursInput) => api.voidUserHours({ id, entryId, reason }), onSuccess: (res, { id, eventId, userName, hours }) => { - qc.invalidateQueries({ queryKey: queryKeys.users.hours(id) }) - qc.invalidateQueries({ queryKey: queryKeys.users.detail(id) }) - qc.invalidateQueries({ queryKey: queryKeys.audit.all }) - if (eventId) qc.invalidateQueries({ queryKey: queryKeys.events.detail(eventId) }) + invalidateLedger(qc, id, eventId) useUiStore.getState().showToast(voidedToast(userName, hours)) if (res.affectedCertificates.length === 0) return void confirmDialog({ diff --git a/apps/admin/src/features/users/user-hours-panel.tsx b/apps/admin/src/features/users/user-hours-panel.tsx index 0d93bff..4e86c1b 100644 --- a/apps/admin/src/features/users/user-hours-panel.tsx +++ b/apps/admin/src/features/users/user-hours-panel.tsx @@ -1,23 +1,43 @@ "use client" import * as React from "react" -import { type AdminUserDTO, type AdminUserHoursEntryDTO } from "@civfix/shared" +import { MAX_EVENT_HOURS, MIN_EVENT_HOURS, type AdminUserDTO, type AdminUserHoursEntryDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" -import { promptDialog } from "@/components/shared/dialog" +import { confirmDialog, promptDialog } from "@/components/shared/dialog" import { formatDateTime } from "@/lib/dates" import { useNav } from "@/store/ui-store" -import { useUserHours, useVoidUserHours } from "@/features/users/use-user-hours" +import { FieldError, ReasonField } from "@/features/orgs/org-form-fields" +import { EventPicker } from "@/features/users/event-picker" import { + useCreditUserHours, + useUserHours, + useVoidUserHours, +} from "@/features/users/use-user-hours" +import { + buildCreditRequest, + creditBlockedReason, + creditConfirmBody, + creditConfirmTitle, + creditDraftErrors, + creditServerErrors, formatHours, hoursEntryMeta, hoursEntryTitle, hoursEntryVoidDetail, + todayLocalIsoDate, + type CreditHoursDraft, + type CreditHoursErrors, + type CreditKind, } from "@/features/users/user-hours" const VOID_REASON_MAX = 1000 +const CREDIT_KINDS: readonly { kind: CreditKind; label: string }[] = [ + { kind: "event", label: "Event credit" }, + { kind: "manual", label: "Manual adjustment" }, +] function EntryIcon({ source }: { source: AdminUserHoursEntryDTO["source"] }) { if (source === "event") { @@ -102,9 +122,181 @@ function HoursEntryRow({ ) } +function CreditHoursForm({ user, onDone }: { user: AdminUserDTO; onDone: () => void }) { + const credit = useCreditUserHours() + const [draft, setDraft] = React.useState(() => ({ + kind: "event", + event: null, + hours: "", + serviceDate: todayLocalIsoDate(), + reason: "", + })) + const [attempted, setAttempted] = React.useState(false) + const [serverErrors, setServerErrors] = React.useState({}) + // Guards a second Enter while the confirmation is open. + const confirming = React.useRef(false) + const mounted = React.useRef(true) + React.useEffect(() => { + mounted.current = true + return () => { + mounted.current = false + } + }, []) + + const today = todayLocalIsoDate() + const pending = credit.isPending + const errors: CreditHoursErrors = { + ...serverErrors, + ...(attempted ? creditDraftErrors(draft, today) : {}), + } + const change = (patch: Partial) => { + setDraft((prev) => ({ ...prev, ...patch })) + setServerErrors({}) + } + + const submit = async (e: React.FormEvent) => { + e.preventDefault() + if (confirming.current || credit.isPending) return + setAttempted(true) + const request = buildCreditRequest(user.id, draft, today) + if (!request) return + confirming.current = true + let ok: boolean + try { + ok = await confirmDialog({ + title: creditConfirmTitle(user.name, request, draft.event?.title ?? null), + body: creditConfirmBody(user.name, request.kind), + confirmLabel: "Credit hours", + }) + } finally { + confirming.current = false + } + if (!ok) return + setServerErrors({}) + credit.mutate( + { request, userName: user.name, showsErrorInline: () => mounted.current }, + { + onSuccess: onDone, + onError: (err) => setServerErrors(creditServerErrors(err)), + }, + ) + } + + return ( +
void submit(e)}> +
Credit hours
+
+
+ + Credit type + +
+ {CREDIT_KINDS.map(({ kind, label }) => ( + + ))} +
+
+ + {draft.kind === "event" ? ( +
+ Event + change({ event })} + disabled={pending} + /> + {errors.event ? ( + + ) : ( + + Only events that have ended can be credited. Each credit is at most the + event's length plus one hour, and a person gets at most 24 h per day. + + )} +
+ ) : ( +
+ + change({ serviceDate: e.target.value })} + /> + {errors.serviceDate ? ( + + ) : ( + + Counts toward the neighbor's total but toward no jurisdiction's + leaderboard. A person gets at most 24 h per day. + + )} +
+ )} + +
+ + change({ hours: e.target.value })} + /> + +
+ + change({ reason })} + error={errors.reason} + placeholder="Attended before signing up; confirmed on the host's paper sign-in sheet…" + disabled={pending} + /> + + +
+ + +
+
+
+ ) +} + export function UserHoursPanel({ user }: { user: AdminUserDTO }) { const q = useUserHours(user.id) const voidHours = useVoidUserHours() + const [crediting, setCrediting] = React.useState(false) + const creditBlocked = creditBlockedReason(user) // Guards a second click while the reason prompt is already open. const prompting = React.useRef(false) @@ -136,21 +328,6 @@ export function UserHoursPanel({ user }: { user: AdminUserDTO }) { }) } - if (q.isLoading) { - return ( -
- -
- ) - } - if (q.isError) { - return ( -
- q.refetch()} /> -
- ) - } - const pages = q.data?.pages ?? [] const items = pages.flatMap((p) => p.items) const totals = pages[0]?.totals @@ -173,8 +350,27 @@ export function UserHoursPanel({ user }: { user: AdminUserDTO }) {
)} + {crediting ? ( + setCrediting(false)} /> + ) : ( +
+ {creditBlocked && {creditBlocked}} + +
+ )}
- {items.length === 0 ? ( + {q.isLoading ? ( + + ) : q.isError ? ( + q.refetch()} /> + ) : items.length === 0 ? ( { ) }) }) + +const EVENT_ID = "00000000-0000-4000-8000-0000000000a1" +const TODAY = "2026-09-26" + +function draft(over: Partial = {}): CreditHoursDraft { + return { + kind: "event", + event: { id: EVENT_ID, title: "Echo Park Lake cleanup", place: "Echo Park", when: "Sep 12, 2026" }, + hours: "2.5", + serviceDate: TODAY, + reason: " Signed the paper sheet before joining ", + ...over, + } +} + +describe("credit hours draft", () => { + it("reads today from the local calendar, zero-padded", () => { + expect(todayLocalIsoDate(new Date(2026, 0, 5, 23, 59))).toBe("2026-01-05") + expect(todayLocalIsoDate(new Date(2026, 11, 31, 0, 1))).toBe("2026-12-31") + }) + + it("bounds hours to the contract's range", () => { + expect(creditDraftErrors(draft({ hours: "0.01" }), TODAY).hours).toBeUndefined() + expect(creditDraftErrors(draft({ hours: "24" }), TODAY).hours).toBeUndefined() + expect(creditDraftErrors(draft({ hours: "" }), TODAY).hours).toBe("Enter the hours to credit.") + for (const bad of ["0", "0.009", "24.01", "-1", "abc", "Infinity"]) { + expect(creditDraftErrors(draft({ hours: bad }), TODAY).hours).toBe("Enter between 0.01 and 24 hours.") + } + }) + + it("requires an event for an event credit and a past-or-today date for a manual one", () => { + expect(creditDraftErrors(draft({ event: null }), TODAY)).toEqual({ event: "Pick the event to credit." }) + expect(creditDraftErrors(draft({ kind: "manual", event: null }), TODAY)).toEqual({}) + expect(creditDraftErrors(draft({ kind: "event", serviceDate: "" }), TODAY)).toEqual({}) + expect(serviceDateDraftError("", TODAY)).toBe("Pick the date of service.") + expect(serviceDateDraftError("2026-09-27", TODAY)).toBe("The date of service can't be in the future.") + expect(serviceDateDraftError("1999-12-31", TODAY)).toMatch(/^The date of service must be on or after /) + expect(serviceDateDraftError(TODAY, TODAY)).toBeNull() + }) + + it("requires a reason that is not only whitespace", () => { + expect(creditDraftErrors(draft({ reason: " " }), TODAY)).toEqual({ reason: "A reason is required." }) + }) + + it("builds the event member with a trimmed reason and no manual fields", () => { + expect(buildCreditRequest(USER_ID, draft(), TODAY)).toEqual({ + id: USER_ID, + kind: "event", + eventId: EVENT_ID, + hours: 2.5, + reason: "Signed the paper sheet before joining", + }) + }) + + it("builds the manual member with the service date and no event", () => { + expect( + buildCreditRequest(USER_ID, draft({ kind: "manual", hours: " 3 ", serviceDate: "2026-09-20" }), TODAY), + ).toEqual({ + id: USER_ID, + kind: "manual", + hours: 3, + serviceDate: "2026-09-20", + reason: "Signed the paper sheet before joining", + }) + }) + + it("builds nothing while the draft has an error", () => { + expect(buildCreditRequest(USER_ID, draft({ event: null }), TODAY)).toBeNull() + expect(buildCreditRequest(USER_ID, draft({ hours: "25" }), TODAY)).toBeNull() + expect(buildCreditRequest(USER_ID, draft({ kind: "manual", serviceDate: "2026-09-27" }), TODAY)).toBeNull() + }) + + it("words the confirmation and the toast", () => { + const event = buildCreditRequest(USER_ID, draft({ hours: "3.5" }), TODAY)! + expect(creditConfirmTitle("Rosa", event, "Echo Park Lake cleanup")).toBe( + "Credit 3.5 h to Rosa for Echo Park Lake cleanup?", + ) + const manualRequest = buildCreditRequest(USER_ID, draft({ kind: "manual", serviceDate: "2026-09-20" }), TODAY)! + expect(creditConfirmTitle("Rosa", manualRequest, null)).toBe( + `Credit 2.5 h to Rosa as a manual adjustment for ${formatServiceDate("2026-09-20")}?`, + ) + expect(creditedToast("Rosa", 1.25)).toBe("Rosa · +1.25 h") + }) + + it("blocks deleted and operator accounts", () => { + expect(creditBlockedReason({ deletedAt: null, role: "citizen" })).toBeNull() + expect(creditBlockedReason({ deletedAt: "2026-09-01T00:00:00.000Z", role: "citizen" })).toBe( + "Hours can't be credited to a deleted account.", + ) + expect(creditBlockedReason({ deletedAt: null, role: "operator" })).toBe( + "Hours can't be credited to an operator account.", + ) + }) +}) + +describe("credit hours server errors", () => { + it("puts validation fields under their inputs", () => { + const err = new AppError(ErrorCode.VALIDATION, "Invalid request", { + fields: { + hours: "this event ran for 2.5 h, so at most 3.5 h may be credited per attendee", + serviceDate: "The service date can't be in the future.", + }, + }) + expect(creditServerErrors(err)).toEqual({ + hours: "This event ran for 2.5 h, so at most 3.5 h may be credited per attendee", + serviceDate: "The service date can't be in the future.", + }) + }) + + it("shows a field the form has no input for on the form, keeping the mapped ones", () => { + const err = new AppError(ErrorCode.VALIDATION, "Invalid request", { + fields: { hours: "too many", kind: "Invalid discriminator value" }, + }) + const errors = creditServerErrors(err) + expect(errors.hours).toBe("Too many") + expect(errors.form).toBe("The server refused these values. Check each field and try again.") + }) + + it("never returns an empty result for a validation error without usable fields", () => { + const err = new AppError(ErrorCode.VALIDATION, "Invalid request", { fields: {} }) + expect(creditServerErrors(err)).toEqual({ + form: "The server refused these values. Check each field and try again.", + }) + }) + + it("shows every other refusal on the form with the server's words", () => { + const err = new AppError(ErrorCode.CONFLICT, "Rosa already holds 2 h for this event; void it first.") + expect(creditServerErrors(err)).toEqual({ + form: "Rosa already holds 2 h for this event; void it first.", + }) + expect(creditServerErrors(new AppError(ErrorCode.RATE_LIMITED, ""))).toEqual({ + form: "Too many credits in a short time. Wait a minute and try again.", + }) + }) +}) diff --git a/apps/admin/src/features/users/user-hours.ts b/apps/admin/src/features/users/user-hours.ts index 923399e..24c0523 100644 --- a/apps/admin/src/features/users/user-hours.ts +++ b/apps/admin/src/features/users/user-hours.ts @@ -1,10 +1,18 @@ import { + ErrorCode, + MAX_EVENT_HOURS, + MIN_EVENT_HOURS, + SERVICE_DATE_MIN, formatCertificateCode, + type AdminCreditUserHoursRequest, + type AdminUserDTO, type AdminUserHoursEntryDTO, type AdminVoidUserHoursResponse, } from "@civfix/shared" +import { toAppError } from "@/lib/api" import { formatDate } from "@/lib/dates" +import { errorMessage } from "@/lib/error-messages" type AffectedCertificate = AdminVoidUserHoursResponse["affectedCertificates"][number] @@ -67,3 +75,155 @@ export function voidedToast(userName: string, hours: number): string { export function affectedCertificateLine(certificate: AffectedCertificate): string { return `${formatCertificateCode(certificate.code)} · issued ${formatDate(certificate.issuedAt)}` } + +export type CreditKind = AdminCreditUserHoursRequest["kind"] + +/** An event as the credit form shows it: `when` is the list row's absolute date label. */ +export interface PickedEvent { + id: string + title: string + place: string + when: string +} + +/** + * The credit form's state. The event and the service date are both kept while the operator switches + * kinds, so toggling back does not lose a pick; only the fields of the chosen kind are validated and sent. + */ +export interface CreditHoursDraft { + kind: CreditKind + event: PickedEvent | null + hours: string + serviceDate: string + reason: string +} + +export type CreditHoursField = "event" | "hours" | "serviceDate" | "reason" +export type CreditHoursErrors = Partial> + +/** + * The service date bound for a manual credit, as the operator's own calendar reads it. The server + * checks the same bound against the platform's time zone and reports a disagreement on `serviceDate`. + */ +export function todayLocalIsoDate(now: Date = new Date()): string { + const month = String(now.getMonth() + 1).padStart(2, "0") + const day = String(now.getDate()).padStart(2, "0") + return `${now.getFullYear()}-${month}-${day}` +} + +export function hoursDraftError(hours: string): string | null { + const text = hours.trim() + if (text === "") return "Enter the hours to credit." + const value = Number(text) + if (!Number.isFinite(value) || value < MIN_EVENT_HOURS || value > MAX_EVENT_HOURS) { + return `Enter between ${MIN_EVENT_HOURS} and ${MAX_EVENT_HOURS} hours.` + } + return null +} + +export function serviceDateDraftError(serviceDate: string, today: string): string | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(serviceDate)) return "Pick the date of service." + if (serviceDate > today) return "The date of service can't be in the future." + if (serviceDate < SERVICE_DATE_MIN) { + return `The date of service must be on or after ${formatServiceDate(SERVICE_DATE_MIN)}.` + } + return null +} + +export function creditDraftErrors(draft: CreditHoursDraft, today: string): CreditHoursErrors { + const errors: CreditHoursErrors = {} + if (draft.kind === "event" && draft.event === null) errors.event = "Pick the event to credit." + const hours = hoursDraftError(draft.hours) + if (hours) errors.hours = hours + if (draft.kind === "manual") { + const date = serviceDateDraftError(draft.serviceDate, today) + if (date) errors.serviceDate = date + } + if (draft.reason.trim() === "") errors.reason = "A reason is required." + return errors +} + +/** The exact union member for the draft's kind, or null while the draft has any error. */ +export function buildCreditRequest( + userId: string, + draft: CreditHoursDraft, + today: string, +): AdminCreditUserHoursRequest | null { + if (Object.keys(creditDraftErrors(draft, today)).length > 0) return null + const hours = Number(draft.hours.trim()) + const reason = draft.reason.trim() + if (draft.kind === "manual") { + return { id: userId, kind: "manual", hours, serviceDate: draft.serviceDate, reason } + } + if (draft.event === null) return null + return { id: userId, kind: "event", eventId: draft.event.id, hours, reason } +} + +/** The server refuses both targets (DECISIONS §58); the form says so up front instead. */ +export function creditBlockedReason(user: Pick): string | null { + if (user.deletedAt) return "Hours can't be credited to a deleted account." + if (user.role === "operator") return "Hours can't be credited to an operator account." + return null +} + +export function creditConfirmTitle( + userName: string, + request: AdminCreditUserHoursRequest, + eventTitle: string | null, +): string { + const hours = formatHours(request.hours) + if (request.kind === "event") return `Credit ${hours} h to ${userName} for ${eventTitle ?? "this event"}?` + return `Credit ${hours} h to ${userName} as a manual adjustment for ${formatServiceDate(request.serviceDate)}?` +} + +export function creditConfirmBody(userName: string, kind: CreditKind): string { + const effect = + kind === "event" + ? "The hours count as if the host had logged them: on the event, in the organization's totals and on the leaderboard." + : "A manual adjustment counts toward the neighbor's total but toward no jurisdiction's leaderboard." + return `${effect} ${userName} is notified and sees CivFix as the creditor. Your reason stays on the admin side.` +} + +export function creditedToast(userName: string, hours: number): string { + return `${userName} · +${formatHours(hours)} h` +} + +function sentence(message: string): string { + return message.charAt(0).toUpperCase() + message.slice(1) +} + +const CREDIT_FIELD_BY_KEY = new Map([ + ["eventId", "event"], + ["hours", "hours"], + ["serviceDate", "serviceDate"], + ["reason", "reason"], +]) + +/** + * Every failed credit renders in the form: a VALIDATION error's `fields` land under the matching field + * (the event window cap arrives on `hours`, a future date in the server's time zone on `serviceDate`), + * and anything else — a live entry already on the event, the daily cap, a refused target, a field this + * form has no input for — on the form, so no refusal is left unshown. + */ +export function creditServerErrors(err: unknown): CreditHoursErrors { + const e = toAppError(err) + const out: CreditHoursErrors = {} + let unmapped = e.code !== ErrorCode.VALIDATION || !e.fields + for (const [key, message] of Object.entries(e.fields ?? {})) { + const field = CREDIT_FIELD_BY_KEY.get(key) + if (field) out[field] = sentence(message) + else unmapped = true + } + if (!unmapped && Object.keys(out).length > 0) return out + return { + ...out, + form: errorMessage( + err, + { + [ErrorCode.VALIDATION]: "The server refused these values. Check each field and try again.", + [ErrorCode.RATE_LIMITED]: "Too many credits in a short time. Wait a minute and try again.", + }, + { fallback: "The hours could not be credited. Please try again." }, + ), + } +} diff --git a/apps/admin/src/lib/query.ts b/apps/admin/src/lib/query.ts index 15443fa..0478538 100644 --- a/apps/admin/src/lib/query.ts +++ b/apps/admin/src/lib/query.ts @@ -73,6 +73,8 @@ export const queryKeys = { events: { all: ["admin", "events"] as const, list: (params?: unknown) => ["admin", "events", "list", params ?? null] as const, + /** One flat page (home preview, event picker) — kept apart from the infinite `list` cache entries. */ + page: (params?: unknown) => ["admin", "events", "page", params ?? null] as const, detail: (id: string) => ["admin", "events", "detail", id] as const, }, diff --git a/apps/admin/src/styles/app.css b/apps/admin/src/styles/app.css index 961810a..51e0873 100644 --- a/apps/admin/src/styles/app.css +++ b/apps/admin/src/styles/app.css @@ -601,6 +601,17 @@ img.org-logo { object-fit: cover; background: var(--paper-2); } .prow-meta.prow-note { white-space: normal; font-style: italic; } .prow.ledger-row.removed .prow-main { opacity: 0.6; } .prow.ledger-row.removed .prow-hours { text-decoration: line-through; } +.user-hours-head { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + margin: var(--space-3) var(--space-4) 0; +} +.user-hours-head .hint { font-size: var(--fs-12); color: var(--ink-3); } +.sub.user-hours-credit { margin: var(--space-3) var(--space-4) 0; } +.user-hours-credit .seg { align-self: flex-start; } +.user-hours-credit .user-hours-amount, .user-hours-credit .user-hours-date { max-width: 180px; } .dialog-list { margin: 0;