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,125 @@
/**
* Tauri adapter for {@link TicketGateway}.
*
* The single frontend owner of the `ticket_*` command names and their request
* envelopes (every command takes one camelCase `request` object). Features
* consume the gateway port through DI and never call `invoke()` directly.
*
* Optimistic concurrency: the backend rejects a stale write with an `INVALID`
* {@link GatewayError} whose message contains `"version conflict"`; use
* {@link isTicketVersionConflict} to branch on it in the UI.
*/
import { invoke } from "@tauri-apps/api/core";
import type {
GatewayError,
Ticket,
TicketCarnet,
TicketLinkKind,
TicketList,
} from "@/domain";
import type {
CreateTicketInput,
TicketGateway,
TicketListQuery,
UpdateTicketInput,
} from "@/ports";
/**
* Whether a gateway error is an optimistic-concurrency conflict (the ticket was
* mutated since it was read). The backend returns a generic `INVALID` code, so
* we key off the stable message fragment emitted by the domain store error.
*/
export function isTicketVersionConflict(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const message = (error as GatewayError).message;
return typeof message === "string" && message.includes("version conflict");
}
export class TauriTicketGateway implements TicketGateway {
async create(projectId: string, input: CreateTicketInput): Promise<Ticket> {
return invoke<Ticket>("ticket_create", {
request: { projectId, ...input },
});
}
async read(
projectId: string,
ref: string,
includeCarnet = false,
): Promise<Ticket> {
return invoke<Ticket>("ticket_read", {
request: { projectId, ref, includeCarnet },
});
}
async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
return invoke<TicketList>("ticket_list", {
request: { projectId, ...(query ?? {}) },
});
}
async update(
projectId: string,
ref: string,
input: UpdateTicketInput,
): Promise<Ticket> {
return invoke<Ticket>("ticket_update", {
request: { projectId, ref, ...input },
});
}
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
return invoke<TicketCarnet>("ticket_read_carnet", {
request: { projectId, ref },
});
}
async updateCarnet(
projectId: string,
ref: string,
carnet: string,
expectedVersion: number,
): Promise<Ticket> {
return invoke<Ticket>("ticket_update_carnet", {
request: { projectId, ref, carnet, expectedVersion },
});
}
async link(
projectId: string,
ref: string,
targetRef: string,
kind: TicketLinkKind,
expectedVersion: number,
): Promise<Ticket> {
return invoke<Ticket>("ticket_link", {
request: { projectId, ref, targetRef, kind, expectedVersion },
});
}
async unlink(
projectId: string,
ref: string,
targetRef: string,
expectedVersion: number,
kind?: TicketLinkKind,
): Promise<Ticket> {
return invoke<Ticket>("ticket_unlink", {
request: { projectId, ref, targetRef, expectedVersion, kind },
});
}
async assign(
projectId: string,
ref: string,
agentId: string,
assigned: boolean,
expectedVersion: number,
): Promise<Ticket> {
return invoke<Ticket>("ticket_assign", {
request: { projectId, ref, agentId, assigned, expectedVersion },
});
}
}