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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions apps/admin/src/features/events/use-events.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<AdminEventListResponse>({
queryKey: queryKeys.events.list(params),
queryKey: queryKeys.events.page(params),
queryFn: () => api.listAdminEvents(params),
...(opts.keepPreviousData ? { placeholderData: keepPreviousData } : {}),
})
}

Expand Down
127 changes: 127 additions & 0 deletions apps/admin/src/features/users/event-picker.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className="prow-ico hue-moss">
<Icons.Calendar size={14} />
</span>
)
}

/**
* 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 (
<div className="user-pick picked">
<EventPickIcon />
<div className="user-pick-text">
<span className="user-pick-name">{value.title}</span>
<span className="user-pick-handle">
{value.place} · {value.when}
</span>
</div>
<button
type="button"
className="btn sm ghost"
disabled={disabled}
onClick={() => onChange(null)}
>
Change
</button>
</div>
)
}

return (
<div className="user-pick">
<div className="searchbox user-pick-search">
<Icons.Search size={14} />
<input
type="text"
aria-label="Search ended events by title or place"
autoComplete="off"
placeholder="Search ended events by title or place…"
value={query}
disabled={disabled}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{list.isLoading ? (
<div className="user-pick-results">
<LoadingState label="Searching…" />
</div>
) : list.isError ? (
<div className="user-pick-results">
<div className="user-pick-note tone-alert">{toAppError(list.error).message}</div>
</div>
) : results.length === 0 ? (
<div className="user-pick-results">
<div className="user-pick-note" role="status">
{q === "" ? "No ended events yet." : `No ended event matches “${q}”.`}
</div>
</div>
) : (
<ul className="user-pick-results" aria-label="Matching events">
{results.map((event) => (
<li key={event.id}>
<button
type="button"
className="user-pick-row"
disabled={disabled}
onClick={() =>
onChange({
id: event.id,
title: event.title,
place: event.place,
when: event.date.abs,
})
}
>
<EventPickIcon />
<span className="user-pick-text">
<span className="user-pick-name">{event.title}</span>
<span className="user-pick-handle">
{event.place} · {event.date.abs}
</span>
</span>
{event.flagged && <span className="pill status-flag tight">flagged</span>}
</button>
</li>
))}
</ul>
)}
</div>
)
}
107 changes: 105 additions & 2 deletions apps/admin/src/features/users/use-user-hours.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof apiModule>()),
api: { voidUserHours: vi.fn() },
api: { voidUserHours: vi.fn(), creditUserHours: vi.fn() },
}))

vi.mock("@/components/shared/dialog", () => ({ confirmDialog: vi.fn() }))
Expand Down Expand Up @@ -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)
})
})
55 changes: 50 additions & 5 deletions apps/admin/src/features/users/use-user-hours.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -31,6 +37,48 @@ export function useUserHours(id: string) {
})
}

function invalidateLedger(
qc: ReturnType<typeof useQueryClient>,
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<typeof useQueryClient>) {
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
Expand All @@ -48,10 +96,7 @@ export function voidUserHoursOptions(qc: ReturnType<typeof useQueryClient>) {
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({
Expand Down
Loading