diff --git a/apps/admin/src/features/events/use-events.ts b/apps/admin/src/features/events/use-events.ts index 08690eb..0e3814f 100644 --- a/apps/admin/src/features/events/use-events.ts +++ b/apps/admin/src/features/events/use-events.ts @@ -1,6 +1,12 @@ "use client" -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { + keepPreviousData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query" import type { AdminEventListQuery, AdminEventListResponse, @@ -16,10 +22,11 @@ import { api } from "@/lib/api" import { queryKeys } from "@/lib/query" -export function useEventList(params: AdminEventListQuery) { +export function useEventList(params: AdminEventListQuery, opts: { keepPreviousData?: boolean } = {}) { return useQuery({ - queryKey: queryKeys.events.list(params), + queryKey: queryKeys.events.page(params), queryFn: () => api.listAdminEvents(params), + ...(opts.keepPreviousData ? { placeholderData: keepPreviousData } : {}), }) } diff --git a/apps/admin/src/features/users/event-picker.tsx b/apps/admin/src/features/users/event-picker.tsx new file mode 100644 index 0000000..cecafa5 --- /dev/null +++ b/apps/admin/src/features/users/event-picker.tsx @@ -0,0 +1,127 @@ +"use client" + +import * as React from "react" + +import { Icons } from "@/components/icons" +import { LoadingState } from "@/components/shared/data-states" +import { useEventList } from "@/features/events/use-events" +import type { PickedEvent } from "@/features/users/user-hours" +import { useDebounced } from "@/hooks/use-debounced" +import { toAppError } from "@/lib/api" + +const RESULT_LIMIT = 8 + +function EventPickIcon() { + return ( + + + + ) +} + +/** + * Search-and-pick an event that can take hours. The `completed` facet is exactly the ended, + * non-cancelled events, so an event the server would refuse for not having ended never appears. + * Once picked, the control collapses to the chosen event with a "Change" affordance. + */ +export function EventPicker({ + value, + onChange, + disabled, +}: { + value: PickedEvent | null + onChange: (event: PickedEvent | null) => void + disabled?: boolean +}) { + const [query, setQuery] = React.useState("") + const debounced = useDebounced(query, 200) + const q = debounced.trim() + const list = useEventList( + { q: q === "" ? undefined : q, filter: "completed", limit: RESULT_LIMIT }, + { keepPreviousData: true }, + ) + const results = list.data?.items ?? [] + + if (value) { + return ( +
+ +
+ {value.title} + + {value.place} · {value.when} + +
+ +
+ ) + } + + 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}”.`} +
+
+ ) : ( + + )} +
+ ) +} 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;