feat(tickets): conservation des tickets en sprint, affichage du statut & filtres (#37)

Les tickets restent rattachés à leur sprint ; SprintManager affiche le
statut et propose des filtres via useTicketSearch. Frontend / read-query.

QA vert : tsc --noEmit exit 0, vitest 62 fichiers / 623 tests passés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 19:55:54 +02:00
parent 338b8707de
commit 1c28754b58
3 changed files with 184 additions and 11 deletions

View File

@ -27,6 +27,7 @@ import type {
TicketStatus,
TicketSummary,
} from "@/domain";
import { isSprintEvent, isTicketEvent } from "@/domain";
import type { TicketListQuery, TicketListSort } from "@/ports";
import { useGateways } from "@/app/di";
@ -54,6 +55,13 @@ export interface UseTicketSearchOptions {
excludeRefs?: TicketRef[];
/** Debounce applied to the free-text search, in ms (default 200). */
debounceMs?: number;
/**
* When true, subscribe to `Issue*`/`sprint*` domain events and re-fetch the
* first page on any ticket/sprint mutation. Off by default because the
* {@link TicketPicker} popup is short-lived and only reads; the sprint view
* (#37) enables it so its independent query stays in sync with add/remove.
*/
refreshOnEvents?: boolean;
}
export interface TicketSearchViewModel {
@ -78,6 +86,8 @@ export interface TicketSearchViewModel {
hasMore: boolean;
/** Loads and appends the next page (no-op when exhausted or busy). */
loadMore: () => void;
/** Forces a first-page re-fetch (e.g. after an external mutation). */
refresh: () => void;
/** Resolves a row's `#ref` to the full picker result (reads id + number). */
resolve: (ref: TicketRef) => Promise<TicketPickerResult>;
}
@ -100,8 +110,9 @@ export function useTicketSearch(
projectId: string,
options: UseTicketSearchOptions = {},
): TicketSearchViewModel {
const { initialQuery, excludeRefs, debounceMs = 200 } = options;
const { ticket } = useGateways();
const { initialQuery, excludeRefs, debounceMs = 200, refreshOnEvents } =
options;
const { ticket, system } = useGateways();
const [facets, setFacets] = useState<Facets>({
statuses: initialQuery?.statuses,
@ -118,6 +129,10 @@ export function useTicketSearch(
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Bumped to force a first-page re-fetch without changing the query (used by
// `refresh` and the domain-event subscription).
const [reloadNonce, setReloadNonce] = useState(0);
const refresh = useCallback(() => setReloadNonce((n) => n + 1), []);
const limit = initialQuery?.limit;
// Stable set so identity only changes when the caller passes new refs.
@ -190,8 +205,31 @@ export function useTicketSearch(
});
// `buildQuery`/`projectId`/`ticket` are captured via `queryKey`; listing
// them would refetch on every render (fresh object identities).
// `reloadNonce` is an explicit refetch trigger (refresh / domain events).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryKey, projectId, ticket]);
}, [queryKey, projectId, ticket, reloadNonce]);
// Optional live refresh: on any ticket/sprint mutation, re-fetch the first
// page so an externally-driven change (e.g. sprint add/remove) reflects here.
useEffect(() => {
if (!refreshOnEvents || !system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (isTicketEvent(event) || isSprintEvent(event)) {
setReloadNonce((n) => n + 1);
}
})
.then((u) => {
if (cancelled) u();
else unsubscribe = u;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [refreshOnEvents, system]);
const loadMore = useCallback(() => {
if (busy || !cursor) return;
@ -277,6 +315,7 @@ export function useTicketSearch(
setSort,
hasMore: cursor !== undefined,
loadMore,
refresh,
resolve,
};
}