merge feature/ticket109-creator-display-and-filter dans develop (créateur affiché + filtre tickets, #109)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -168,6 +168,17 @@ function sortTickets(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameTicketCreator(
|
||||||
|
actor: Ticket["createdBy"],
|
||||||
|
filter: NonNullable<TicketListQuery["createdBy"]>,
|
||||||
|
): boolean {
|
||||||
|
if (actor.kind !== filter.kind) return false;
|
||||||
|
if (actor.kind === "agent" && filter.kind === "agent") {
|
||||||
|
return actor.agentId === filter.agentId;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export class MockSystemGateway implements SystemGateway {
|
export class MockSystemGateway implements SystemGateway {
|
||||||
private listeners = new Set<(e: DomainEvent) => void>();
|
private listeners = new Set<(e: DomainEvent) => void>();
|
||||||
|
|
||||||
@ -2840,6 +2851,7 @@ export class MockTicketGateway implements TicketGateway {
|
|||||||
(t) =>
|
(t) =>
|
||||||
!q.assignedAgentId || t.assignedAgentIds.includes(q.assignedAgentId),
|
!q.assignedAgentId || t.assignedAgentIds.includes(q.assignedAgentId),
|
||||||
)
|
)
|
||||||
|
.filter((t) => !q.createdBy || sameTicketCreator(t.createdBy, q.createdBy))
|
||||||
.filter(
|
.filter(
|
||||||
(t) =>
|
(t) =>
|
||||||
!text ||
|
!text ||
|
||||||
@ -2861,6 +2873,7 @@ export class MockTicketGateway implements TicketGateway {
|
|||||||
priority: t.priority,
|
priority: t.priority,
|
||||||
sprintId: t.sprintId ?? null,
|
sprintId: t.sprintId ?? null,
|
||||||
assignedAgentIds: [...t.assignedAgentIds],
|
assignedAgentIds: [...t.assignedAgentIds],
|
||||||
|
createdBy: structuredClone(t.createdBy),
|
||||||
updatedAt: t.updatedAt,
|
updatedAt: t.updatedAt,
|
||||||
})),
|
})),
|
||||||
...(end < rows.length ? { nextCursor: String(end) } : {}),
|
...(end < rows.length ? { nextCursor: String(end) } : {}),
|
||||||
|
|||||||
@ -39,4 +39,19 @@ describe("TauriTicketGateway invoke payloads", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes createdBy through the ticket list request DTO", async () => {
|
||||||
|
await new TauriTicketGateway().list("proj-1", {
|
||||||
|
createdBy: { kind: "agent", agentId: "agent-1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("ticket_list", {
|
||||||
|
request: {
|
||||||
|
projectId: "proj-1",
|
||||||
|
statuses: [],
|
||||||
|
priorities: [],
|
||||||
|
createdBy: { kind: "agent", agentId: "agent-1" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1528,6 +1528,7 @@ export interface TicketSummary {
|
|||||||
/** Sprint membership (`null` ⇒ no sprint). Additive (ticket #10). */
|
/** Sprint membership (`null` ⇒ no sprint). Additive (ticket #10). */
|
||||||
sprintId?: string | null;
|
sprintId?: string | null;
|
||||||
assignedAgentIds: string[];
|
assignedAgentIds: string[];
|
||||||
|
createdBy: TicketActor;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { useProjectAgents } from "./useProjectAgents";
|
|||||||
import { TicketAssistantPanel } from "./TicketAssistantPanel";
|
import { TicketAssistantPanel } from "./TicketAssistantPanel";
|
||||||
import { TicketPicker } from "./TicketPicker";
|
import { TicketPicker } from "./TicketPicker";
|
||||||
import { TicketViewportSelect } from "./TicketViewportSelect";
|
import { TicketViewportSelect } from "./TicketViewportSelect";
|
||||||
|
import { ticketActorLabel } from "./ticketActor";
|
||||||
import {
|
import {
|
||||||
PriorityBadge,
|
PriorityBadge,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@ -256,6 +257,9 @@ export function TicketDetail({
|
|||||||
<div className="-mx-4">
|
<div className="-mx-4">
|
||||||
{/* ── Fields (F3) ── */}
|
{/* ── Fields (F3) ── */}
|
||||||
<Section title="Details">
|
<Section title="Details">
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Créé par : {ticketActorLabel(t.createdBy, nameOf)}
|
||||||
|
</p>
|
||||||
<label className="text-xs font-medium text-muted" htmlFor="td-title">
|
<label className="text-xs font-medium text-muted" htmlFor="td-title">
|
||||||
Title
|
Title
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@ -17,6 +17,11 @@ import { SprintManager } from "./SprintManager";
|
|||||||
import { SprintPicker } from "./SprintPicker";
|
import { SprintPicker } from "./SprintPicker";
|
||||||
import { TicketFacetsBar } from "./TicketFacetsBar";
|
import { TicketFacetsBar } from "./TicketFacetsBar";
|
||||||
import { TicketViewportSelect } from "./TicketViewportSelect";
|
import { TicketViewportSelect } from "./TicketViewportSelect";
|
||||||
|
import {
|
||||||
|
creatorFilterFromValue,
|
||||||
|
creatorFilterValue,
|
||||||
|
ticketActorLabel,
|
||||||
|
} from "./ticketActor";
|
||||||
import {
|
import {
|
||||||
PriorityBadge,
|
PriorityBadge,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@ -37,17 +42,26 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
const vm = useTickets(projectId);
|
const vm = useTickets(projectId);
|
||||||
const { agents, nameOf, loaded: agentsLoaded } = useProjectAgents(projectId);
|
const { agents, nameOf, loaded: agentsLoaded } = useProjectAgents(projectId);
|
||||||
|
|
||||||
// Reconcile a *restored* assignee filter (ticket #29) against the live roster:
|
// Reconcile *restored* agent-scoped filters (ticket #29/#109) against the live
|
||||||
// once the agents are known, an assignee that no longer exists is dropped
|
// roster: once agents are known, stale ids are dropped and persisted storage
|
||||||
// (which also clears it from persisted storage via `useTickets`). Guarded on
|
// follows via `useTickets`. Guarded on `agentsLoaded` so a still-valid agent is
|
||||||
// `agentsLoaded` so a still-valid assignee is never cleared during the load
|
// never cleared during the load window when the roster is momentarily empty.
|
||||||
// window when the roster is momentarily empty.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!agentsLoaded) return;
|
if (!agentsLoaded) return;
|
||||||
const id = vm.query.assignedAgentId;
|
const validAgentIds = new Set(agents.map((a) => a.id));
|
||||||
if (id && !agents.some((a) => a.id === id)) {
|
const staleAssignee =
|
||||||
const { assignedAgentId: _drop, ...rest } = vm.query;
|
vm.query.assignedAgentId && !validAgentIds.has(vm.query.assignedAgentId);
|
||||||
vm.setQuery(rest);
|
const staleCreator =
|
||||||
|
vm.query.createdBy?.kind === "agent" &&
|
||||||
|
!validAgentIds.has(vm.query.createdBy.agentId);
|
||||||
|
if (staleAssignee || staleCreator) {
|
||||||
|
const { assignedAgentId: _dropAssignee, createdBy: _dropCreator, ...rest } =
|
||||||
|
vm.query;
|
||||||
|
vm.setQuery({
|
||||||
|
...rest,
|
||||||
|
...(staleAssignee ? {} : { assignedAgentId: vm.query.assignedAgentId }),
|
||||||
|
...(staleCreator ? {} : { createdBy: vm.query.createdBy }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [agentsLoaded, agents, vm.query, vm.setQuery]);
|
}, [agentsLoaded, agents, vm.query, vm.setQuery]);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
@ -305,6 +319,26 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<TicketViewportSelect
|
||||||
|
aria-label="filter by creator"
|
||||||
|
value={creatorFilterValue(vm.query.createdBy)}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "Tous créateurs" },
|
||||||
|
{ value: "user", label: "Utilisateur" },
|
||||||
|
...agents.map((agent) => ({
|
||||||
|
value: `agent:${agent.id}`,
|
||||||
|
label: agent.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
onChange={(next) => {
|
||||||
|
const { createdBy: _drop, ...rest } = vm.query;
|
||||||
|
const createdBy = creatorFilterFromValue(next);
|
||||||
|
vm.setQuery({
|
||||||
|
...rest,
|
||||||
|
...(createdBy ? { createdBy } : {}),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -485,6 +519,9 @@ function SprintSection({
|
|||||||
{t.assignedAgentIds.map(nameOf).join(", ")}
|
{t.assignedAgentIds.map(nameOf).join(", ")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<span className="mt-0.5 block truncate text-xs text-muted">
|
||||||
|
Créé par : {ticketActorLabel(t.createdBy, nameOf)}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||||
<StatusBadge status={t.status} />
|
<StatusBadge status={t.status} />
|
||||||
|
|||||||
37
frontend/src/features/tickets/ticketActor.ts
Normal file
37
frontend/src/features/tickets/ticketActor.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import type { TicketActor } from "@/domain";
|
||||||
|
|
||||||
|
export type TicketCreatorFilterValue = "" | "user" | `agent:${string}`;
|
||||||
|
|
||||||
|
export function ticketActorLabel(
|
||||||
|
actor: TicketActor,
|
||||||
|
nameOf: (agentId: string) => string,
|
||||||
|
): string {
|
||||||
|
switch (actor.kind) {
|
||||||
|
case "agent":
|
||||||
|
return `Agent : ${nameOf(actor.agentId)}`;
|
||||||
|
case "system":
|
||||||
|
return "Système";
|
||||||
|
case "user":
|
||||||
|
default:
|
||||||
|
return "Utilisateur";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function creatorFilterValue(
|
||||||
|
actor: { kind: "user" } | { kind: "agent"; agentId: string } | undefined,
|
||||||
|
): TicketCreatorFilterValue {
|
||||||
|
if (!actor) return "";
|
||||||
|
if (actor.kind === "user") return "user";
|
||||||
|
return `agent:${actor.agentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function creatorFilterFromValue(
|
||||||
|
value: string,
|
||||||
|
): { kind: "user" } | { kind: "agent"; agentId: string } | undefined {
|
||||||
|
if (value === "user") return { kind: "user" };
|
||||||
|
if (value.startsWith("agent:")) {
|
||||||
|
const agentId = value.slice("agent:".length);
|
||||||
|
return agentId ? { kind: "agent", agentId } : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@ -69,6 +69,22 @@ describe("parseTicketFilters (validation)", () => {
|
|||||||
.assignedAgentId,
|
.assignedAgentId,
|
||||||
).toBe("gone");
|
).toBe("gone");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps valid creator filters and drops obsolete creator agents", () => {
|
||||||
|
expect(parseTicketFilters({ createdBy: { kind: "user" } }).createdBy).toEqual({
|
||||||
|
kind: "user",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
parseTicketFilters({ createdBy: { kind: "agent", agentId: "a1" } }, {
|
||||||
|
validAgentIds: new Set(["a1"]),
|
||||||
|
}).createdBy,
|
||||||
|
).toEqual({ kind: "agent", agentId: "a1" });
|
||||||
|
expect(
|
||||||
|
parseTicketFilters({ createdBy: { kind: "agent", agentId: "gone" } }, {
|
||||||
|
validAgentIds: new Set(["a1"]),
|
||||||
|
}).createdBy,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sanitize (persistable subsets)", () => {
|
describe("sanitize (persistable subsets)", () => {
|
||||||
@ -77,6 +93,7 @@ describe("sanitize (persistable subsets)", () => {
|
|||||||
statuses: ["open"],
|
statuses: ["open"],
|
||||||
priorities: ["high"],
|
priorities: ["high"],
|
||||||
assignedAgentId: "a1",
|
assignedAgentId: "a1",
|
||||||
|
createdBy: { kind: "agent", agentId: "a1" },
|
||||||
sort: { field: "title", direction: "asc" },
|
sort: { field: "title", direction: "asc" },
|
||||||
limit: 100,
|
limit: 100,
|
||||||
cursor: "opaque",
|
cursor: "opaque",
|
||||||
@ -89,6 +106,7 @@ describe("sanitize (persistable subsets)", () => {
|
|||||||
statuses: ["open"],
|
statuses: ["open"],
|
||||||
priorities: ["high"],
|
priorities: ["high"],
|
||||||
assignedAgentId: "a1",
|
assignedAgentId: "a1",
|
||||||
|
createdBy: { kind: "agent", agentId: "a1" },
|
||||||
sort: { field: "title", direction: "asc" },
|
sort: { field: "title", direction: "asc" },
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
@ -104,6 +122,7 @@ describe("sanitize (persistable subsets)", () => {
|
|||||||
sort: { field: "title", direction: "asc" },
|
sort: { field: "title", direction: "asc" },
|
||||||
});
|
});
|
||||||
expect("assignedAgentId" in out).toBe(false);
|
expect("assignedAgentId" in out).toBe(false);
|
||||||
|
expect("createdBy" in out).toBe(false);
|
||||||
expect("limit" in out).toBe(false);
|
expect("limit" in out).toBe(false);
|
||||||
expect("cursor" in out).toBe(false);
|
expect("cursor" in out).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
* - `tickets:pickerFilters:<projectId>` — the reusable ticket picker.
|
* - `tickets:pickerFilters:<projectId>` — the reusable ticket picker.
|
||||||
*
|
*
|
||||||
* We only ever store the *stable* criteria (`text`, `statuses`, `priorities`,
|
* We only ever store the *stable* criteria (`text`, `statuses`, `priorities`,
|
||||||
* `sort`, `limit`, and — main list only — `assignedAgentId`). The opaque
|
* `sort`, `limit`, and — main list only — `assignedAgentId`/`createdBy`). The opaque
|
||||||
* `cursor` is **never** persisted: it is a transient pagination token whose reuse
|
* `cursor` is **never** persisted: it is a transient pagination token whose reuse
|
||||||
* across sessions is meaningless (G3-bis). Reads are defensive — unknown values
|
* across sessions is meaningless (G3-bis). Reads are defensive — unknown values
|
||||||
* are dropped and a corrupt blob yields the default empty query.
|
* are dropped and a corrupt blob yields the default empty query.
|
||||||
@ -133,6 +133,21 @@ export function parseTicketFilters(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isObject(raw.createdBy)) {
|
||||||
|
if (raw.createdBy.kind === "user") {
|
||||||
|
query.createdBy = { kind: "user" };
|
||||||
|
} else if (
|
||||||
|
raw.createdBy.kind === "agent" &&
|
||||||
|
typeof raw.createdBy.agentId === "string" &&
|
||||||
|
raw.createdBy.agentId
|
||||||
|
) {
|
||||||
|
const { validAgentIds } = options;
|
||||||
|
if (!validAgentIds || validAgentIds.has(raw.createdBy.agentId)) {
|
||||||
|
query.createdBy = { kind: "agent", agentId: raw.createdBy.agentId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sort = parseSort(raw.sort);
|
const sort = parseSort(raw.sort);
|
||||||
if (sort) query.sort = sort;
|
if (sort) query.sort = sort;
|
||||||
|
|
||||||
@ -161,6 +176,7 @@ export function sanitizeListFilters(query: TicketListQuery): TicketListQuery | n
|
|||||||
out.priorities = query.priorities;
|
out.priorities = query.priorities;
|
||||||
}
|
}
|
||||||
if (query.assignedAgentId) out.assignedAgentId = query.assignedAgentId;
|
if (query.assignedAgentId) out.assignedAgentId = query.assignedAgentId;
|
||||||
|
if (query.createdBy) out.createdBy = query.createdBy;
|
||||||
if (query.sort) out.sort = query.sort;
|
if (query.sort) out.sort = query.sort;
|
||||||
if (typeof query.limit === "number") out.limit = query.limit;
|
if (typeof query.limit === "number") out.limit = query.limit;
|
||||||
return Object.keys(out).length > 0 ? out : null;
|
return Object.keys(out).length > 0 ? out : null;
|
||||||
|
|||||||
@ -103,6 +103,28 @@ describe("MockTicketGateway", () => {
|
|||||||
expect(search.items.map((i) => i.ref)).toEqual(["#2"]);
|
expect(search.items.map((i) => i.ref)).toEqual(["#2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("filters by creator and exposes createdBy on summaries (#109)", async () => {
|
||||||
|
await ticket.create(PROJECT_ID, { title: "User ticket" });
|
||||||
|
const agentTicket = await ticket.create(PROJECT_ID, { title: "Agent ticket" });
|
||||||
|
ticket._seedTicket(PROJECT_ID, {
|
||||||
|
...agentTicket,
|
||||||
|
createdBy: { kind: "agent", agentId: "agent-1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const byUser = await ticket.list(PROJECT_ID, { createdBy: { kind: "user" } });
|
||||||
|
expect(byUser.items.map((i) => i.title)).toEqual(["User ticket"]);
|
||||||
|
expect(byUser.items[0]?.createdBy).toEqual({ kind: "user" });
|
||||||
|
|
||||||
|
const byAgent = await ticket.list(PROJECT_ID, {
|
||||||
|
createdBy: { kind: "agent", agentId: "agent-1" },
|
||||||
|
});
|
||||||
|
expect(byAgent.items.map((i) => i.title)).toEqual(["Agent ticket"]);
|
||||||
|
expect(byAgent.items[0]?.createdBy).toEqual({
|
||||||
|
kind: "agent",
|
||||||
|
agentId: "agent-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("combines status and priority facets with AND (#12)", async () => {
|
it("combines status and priority facets with AND (#12)", async () => {
|
||||||
await ticket.create(PROJECT_ID, {
|
await ticket.create(PROJECT_ID, {
|
||||||
title: "a",
|
title: "a",
|
||||||
@ -419,6 +441,45 @@ describe("TicketsView", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows ticket creators and filters the list by creator (#109)", async () => {
|
||||||
|
const creator = await seedAgent(agent, "CreatorAgent");
|
||||||
|
await ticket.create(PROJECT_ID, { title: "Created by user" });
|
||||||
|
const agentTicket = await ticket.create(PROJECT_ID, {
|
||||||
|
title: "Created by agent",
|
||||||
|
});
|
||||||
|
ticket._seedTicket(PROJECT_ID, {
|
||||||
|
...agentTicket,
|
||||||
|
createdBy: { kind: "agent", agentId: creator.id },
|
||||||
|
});
|
||||||
|
const listSpy = vi.spyOn(ticket, "list");
|
||||||
|
|
||||||
|
renderView(ticket, system, agent);
|
||||||
|
|
||||||
|
await screen.findByText("Created by user");
|
||||||
|
expect(screen.getByText("Créé par : Utilisateur")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Créé par : Agent : CreatorAgent")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("filter by creator"));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "CreatorAgent" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(listSpy.mock.calls.at(-1)?.[1]?.createdBy).toEqual({
|
||||||
|
kind: "agent",
|
||||||
|
agentId: creator.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.queryByText("Created by user")).toBeNull());
|
||||||
|
expect(screen.getByText("Created by agent")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Created by agent"));
|
||||||
|
const dialog = await screen.findByRole("dialog", {
|
||||||
|
name: `ticket ${agentTicket.ref}`,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
within(dialog).getByText("Créé par : Agent : CreatorAgent"),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("opens the detail and edits status with a version bump", async () => {
|
it("opens the detail and edits status with a version bump", async () => {
|
||||||
const t = await ticket.create(PROJECT_ID, { title: "Editable" });
|
const t = await ticket.create(PROJECT_ID, { title: "Editable" });
|
||||||
renderView(ticket, system, agent);
|
renderView(ticket, system, agent);
|
||||||
|
|||||||
@ -998,6 +998,7 @@ export interface TicketListQuery {
|
|||||||
statuses?: TicketStatus[];
|
statuses?: TicketStatus[];
|
||||||
priorities?: TicketPriority[];
|
priorities?: TicketPriority[];
|
||||||
assignedAgentId?: string;
|
assignedAgentId?: string;
|
||||||
|
createdBy?: TicketCreatorFilter;
|
||||||
/** Free-text match over title/description. */
|
/** Free-text match over title/description. */
|
||||||
text?: string;
|
text?: string;
|
||||||
sort?: TicketListSort;
|
sort?: TicketListSort;
|
||||||
@ -1005,6 +1006,10 @@ export interface TicketListQuery {
|
|||||||
cursor?: string;
|
cursor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TicketCreatorFilter =
|
||||||
|
| { kind: "user" }
|
||||||
|
| { kind: "agent"; agentId: string };
|
||||||
|
|
||||||
export type TicketListSortField = "number" | "priority" | "status" | "title";
|
export type TicketListSortField = "number" | "priority" | "status" | "title";
|
||||||
|
|
||||||
export type TicketListSortDirection = "asc" | "desc";
|
export type TicketListSortDirection = "asc" | "desc";
|
||||||
|
|||||||
Reference in New Issue
Block a user