feat(tickets): surface frontend V1 du système de tickets

Surface UI complète des tickets (domaine Issue exposé « ticket »),
validée QA de bout en bout : cargo build + tests backend verts,
npm run build + npm test (459) verts.

- Domaine : DTO Ticket, 9 events Issue* + guard isTicketEvent.
- Ports : TicketGateway (+ types query/input) ajouté à Gateways.
- Adapters : TauriTicketGateway réel (+ isTicketVersionConflict),
  wiring, et MockTicketGateway pour les tests.
- Feature tickets : hooks (useTickets, useTicketDetail,
  useProjectAgents), TicketsPanel, TicketDetail, TicketsView,
  ticketMeta + tests.
- ProjectsView : onglet sidebar « Tickets ».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 22:56:36 +02:00
parent 8de7be01a8
commit c1d82eee8d
16 changed files with 2150 additions and 1 deletions

View File

@ -0,0 +1,179 @@
/**
* View-model hook for a single ticket's detail/edit surface (F3/F4/F5).
*
* Owns optimistic-concurrency handling: every mutation sends the ticket's
* current `version` as `expectedVersion`. When the backend reports a version
* conflict (the ticket moved underneath — e.g. an agent edited it), the hook
* flips `conflict`, reloads the fresh ticket and surfaces a clear message so the
* user can re-apply their change against the new version.
*
* The loaded ticket always includes its carnet body (F4); mutations that return
* a carnet-less ticket preserve the last known carnet so the editor never blanks
* out unexpectedly.
*/
import { useCallback, useEffect, useState } from "react";
import { isTicketEvent, type GatewayError, type Ticket, type TicketLinkKind } from "@/domain";
import { useGateways } from "@/app/di";
export interface TicketDetailViewModel {
ticket: Ticket | null;
error: string | null;
/** Set when the last write lost an optimistic-concurrency race (F3). */
conflict: boolean;
busy: boolean;
refresh: () => Promise<void>;
updateFields: (input: {
title?: string;
description?: string;
status?: Ticket["status"];
priority?: Ticket["priority"];
}) => Promise<boolean>;
saveCarnet: (carnet: string) => Promise<boolean>;
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
assign: (agentId: string, assigned: boolean) => Promise<boolean>;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
/** A generic `INVALID` from the backend whose message flags a version conflict. */
function isVersionConflict(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
typeof (e as GatewayError).message === "string" &&
(e as GatewayError).message.includes("version conflict")
);
}
export function useTicketDetail(
projectId: string,
ref: string,
): TicketDetailViewModel {
const { ticket: gateway, system } = useGateways();
const [ticket, setTicket] = useState<Ticket | null>(null);
const [error, setError] = useState<string | null>(null);
const [conflict, setConflict] = useState(false);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
setBusy(true);
setError(null);
try {
setTicket(await gateway.read(projectId, ref, true));
setConflict(false);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}, [projectId, ref, gateway]);
useEffect(() => {
void refresh();
}, [refresh]);
// Refresh when *another* actor mutates this same ticket while it is open.
useEffect(() => {
if (!system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (isTicketEvent(event) && event.issueRef === ref) void refresh();
})
.then((u) => {
if (cancelled) u();
else unsubscribe = u;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [refresh, system, ref]);
/**
* Runs one mutation, preserving the loaded carnet (mutations return a
* carnet-less ticket) and translating a version conflict into `conflict` +
* a reload so the UI can prompt a clean retry.
*/
const run = useCallback(
async (op: (version: number) => Promise<Ticket>, carnet?: string): Promise<boolean> => {
if (!ticket) return false;
setBusy(true);
setError(null);
try {
const updated = await op(ticket.version);
setTicket({
...updated,
carnet: carnet !== undefined ? carnet : ticket.carnet,
});
setConflict(false);
return true;
} catch (e) {
if (isVersionConflict(e)) {
setConflict(true);
setError(
"This ticket was modified elsewhere. It has been reloaded — re-apply your change.",
);
await refresh();
} else {
setError(describe(e));
}
return false;
} finally {
setBusy(false);
}
},
[ticket, gateway, refresh],
);
const updateFields: TicketDetailViewModel["updateFields"] = useCallback(
(input) => run((version) => gateway.update(projectId, ref, { ...input, expectedVersion: version })),
[run, gateway, projectId, ref],
);
const saveCarnet: TicketDetailViewModel["saveCarnet"] = useCallback(
(carnet) =>
run((version) => gateway.updateCarnet(projectId, ref, carnet, version), carnet),
[run, gateway, projectId, ref],
);
const link: TicketDetailViewModel["link"] = useCallback(
(targetRef, kind) =>
run((version) => gateway.link(projectId, ref, targetRef, kind, version)),
[run, gateway, projectId, ref],
);
const unlink: TicketDetailViewModel["unlink"] = useCallback(
(targetRef, kind) =>
run((version) => gateway.unlink(projectId, ref, targetRef, version, kind)),
[run, gateway, projectId, ref],
);
const assign: TicketDetailViewModel["assign"] = useCallback(
(agentId, assigned) =>
run((version) => gateway.assign(projectId, ref, agentId, assigned, version)),
[run, gateway, projectId, ref],
);
return {
ticket,
error,
conflict,
busy,
refresh,
updateFields,
saveCarnet,
link,
unlink,
assign,
};
}