diff --git a/GerbilManager.Tests/RefsEndpointTests.cs b/GerbilManager.Tests/RefsEndpointTests.cs
new file mode 100644
index 0000000..d80116a
--- /dev/null
+++ b/GerbilManager.Tests/RefsEndpointTests.cs
@@ -0,0 +1,123 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using GerbilManagerWebAPI.Models;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace GerbilManager.Tests;
+
+///
+/// REFS: POST /refs/resolve löst getippte Kurz-Verweise (tier|kontakt|wurf|gehege) auf.
+/// - exakter (volle GUID) und kurzer (Präfix) Treffer je Typ
+/// - mehrdeutiges Präfix (mehrere Treffer) => id=null (nicht geraten)
+/// - unbekannter Code / unbekannter Typ => id=null
+/// Entitäten werden mit deterministischen GUIDs direkt in die SQLite-Test-DB geseedet,
+/// damit Präfixe und Mehrdeutigkeit kontrolliert getestet werden können.
+///
+public class RefsEndpointTests : IClassFixture
+{
+ private readonly ApiFactory _factory;
+ public RefsEndpointTests(ApiFactory factory) => _factory = factory;
+
+ // Deterministische GUIDs: zwei Tiere mit GEMEINSAMEM 6-Hex-Präfix "abcdef" (mehrdeutig),
+ // sowie je eine eindeutige Entität pro Typ.
+ private static readonly Guid TierA = new("abcdef00-0000-0000-0000-000000000001");
+ private static readonly Guid TierB = new("abcdef00-0000-0000-0000-000000000002");
+ private static readonly Guid Kontakt = new("11112222-3333-4444-5555-666677778888");
+ private static readonly Guid Wurf = new("99990000-aaaa-bbbb-cccc-ddddeeeeffff");
+ private static readonly Guid Gehege = new("12345678-9abc-def0-1234-567890abcdef");
+
+ private async Task SeedAsync()
+ {
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ if (db.Gerbils.Any(g => g.Id == TierA)) return; // einmalig (geteilte Fixture)
+
+ db.Gerbils.Add(new Gerbil { Id = TierA, Name = "Krümel", Gender = Gender.female });
+ db.Gerbils.Add(new Gerbil { Id = TierB, Name = "Möhre", Gender = Gender.male });
+ db.Contacts.Add(new Contact { Id = Kontakt, Name = "Erika Musterfrau" });
+ db.Litters.Add(new Litter { Id = Wurf, Name = "Wurf X", Date = new DateOnly(2026, 1, 1) });
+ db.Enclosures.Add(new Enclosure { Id = Gehege, Name = "Terrarium 1" });
+ await db.SaveChangesAsync();
+ }
+
+ private async Task> Resolve(params object[] refs)
+ {
+ var resp = await _factory.CreateClient()
+ .PostAsJsonAsync("/refs/resolve", new { refs });
+ resp.EnsureSuccessStatusCode();
+ var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
+ return doc.RootElement.EnumerateArray().ToList();
+ }
+
+ [Fact]
+ public async Task Resolves_full_guid_per_type()
+ {
+ await SeedAsync();
+ var rows = await Resolve(
+ new { type = "tier", code = TierA.ToString() },
+ new { type = "kontakt", code = Kontakt.ToString() },
+ new { type = "wurf", code = Wurf.ToString() },
+ new { type = "gehege", code = Gehege.ToString() });
+
+ Assert.Equal(TierA.ToString(), rows[0].GetProperty("id").GetString());
+ Assert.Equal("Krümel", rows[0].GetProperty("name").GetString());
+ Assert.Equal(Kontakt.ToString(), rows[1].GetProperty("id").GetString());
+ Assert.Equal("Erika Musterfrau", rows[1].GetProperty("name").GetString());
+ Assert.Equal(Wurf.ToString(), rows[2].GetProperty("id").GetString());
+ Assert.Equal("Wurf X", rows[2].GetProperty("name").GetString());
+ Assert.Equal(Gehege.ToString(), rows[3].GetProperty("id").GetString());
+ Assert.Equal("Terrarium 1", rows[3].GetProperty("name").GetString());
+ }
+
+ [Fact]
+ public async Task Resolves_short_prefix_when_unique()
+ {
+ await SeedAsync();
+ // 8-Hex-Kurz-ID des Kontakts ("11112222") ist eindeutig.
+ var rows = await Resolve(new { type = "kontakt", code = "11112222" });
+ Assert.Equal(Kontakt.ToString(), rows[0].GetProperty("id").GetString());
+ Assert.Equal("Erika Musterfrau", rows[0].GetProperty("name").GetString());
+ }
+
+ [Fact]
+ public async Task Tolerates_dashes_and_case_in_code()
+ {
+ await SeedAsync();
+ // Gemischte Schreibweise + Bindestriche im Kurz-Code des Geheges.
+ var rows = await Resolve(new { type = "gehege", code = "1234-5678" });
+ Assert.Equal(Gehege.ToString(), rows[0].GetProperty("id").GetString());
+ Assert.Equal("Terrarium 1", rows[0].GetProperty("name").GetString());
+ }
+
+ [Fact]
+ public async Task Ambiguous_prefix_resolves_to_null()
+ {
+ await SeedAsync();
+ // "abcdef" trifft TierA UND TierB => mehrdeutig => id/name null.
+ var rows = await Resolve(new { type = "tier", code = "abcdef" });
+ Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind);
+ Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("name").ValueKind);
+ Assert.Equal("tier", rows[0].GetProperty("type").GetString());
+ Assert.Equal("abcdef", rows[0].GetProperty("code").GetString());
+ }
+
+ [Fact]
+ public async Task Unknown_code_and_unknown_type_resolve_to_null()
+ {
+ await SeedAsync();
+ var rows = await Resolve(
+ new { type = "tier", code = "fffffffe" }, // unbekannter Code
+ new { type = "schubladen", code = "11112222" }); // unbekannter Typ
+ Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind);
+ Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("id").ValueKind);
+ }
+
+ [Fact]
+ public async Task Too_short_code_resolves_to_null()
+ {
+ await SeedAsync();
+ // < 6 Hex-Zeichen wird nicht aufgelöst (auch wenn es eindeutig wäre).
+ var rows = await Resolve(new { type = "kontakt", code = "1111" });
+ Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind);
+ }
+}
diff --git a/GerbilManagerWebAPI/Dtos/RefsDtos.cs b/GerbilManagerWebAPI/Dtos/RefsDtos.cs
new file mode 100644
index 0000000..4276442
--- /dev/null
+++ b/GerbilManagerWebAPI/Dtos/RefsDtos.cs
@@ -0,0 +1,19 @@
+namespace GerbilManagerWebAPI.Dtos
+{
+ ///
+ /// REFS: ein einzelner aufzulösender Kurz-Verweis (Shortlink, git-Stil) aus einem
+ /// Freitext. Type ist "tier" | "kontakt" | "wurf" | "gehege"; Code ist
+ /// ein GUID-Präfix (mind. 6 Hex-Zeichen) ODER die volle GUID — Bindestrich-tolerant.
+ ///
+ public record RefRequest(string Type, string Code);
+
+ /// REFS: Body von POST /refs/resolve.
+ public record ResolveRefsInput(List Refs);
+
+ ///
+ /// REFS: Auflösungsergebnis. Id/Name sind null, wenn der Code unbekannt
+ /// ODER mehrdeutig ist (mehrere Präfix-Treffer) — es wird bewusst nicht geraten.
+ /// Type/Code werden gespiegelt, damit der Client zuordnen kann.
+ ///
+ public record RefResolution(string Type, string Code, Guid? Id, string? Name);
+}
diff --git a/GerbilManagerWebAPI/Endpoints/RefsEndpoints.cs b/GerbilManagerWebAPI/Endpoints/RefsEndpoints.cs
new file mode 100644
index 0000000..b76b48a
--- /dev/null
+++ b/GerbilManagerWebAPI/Endpoints/RefsEndpoints.cs
@@ -0,0 +1,107 @@
+using GerbilManagerWebAPI.Dtos;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.EntityFrameworkCore;
+
+namespace GerbilManagerWebAPI.Endpoints
+{
+ ///
+ /// REFS: Auflöser für getippte Kurz-Verweise (Shortlinks, git-Stil) in Freitexten.
+ /// Tokens im Text: tier:<code>, kontakt:<code>,
+ /// wurf:<code>, gehege:<code> — <code> ist ein
+ /// GUID-Präfix (mind. 6 Hex) oder die volle GUID. Das Frontend rendert daraus
+ /// Name + interner Link.
+ ///
+ /// POST /refs/resolve -> [{ type, code, id|null, name|null }]
+ ///
+ /// DB-AGNOSTISCH (Tests: SQLite in-memory, Prod: Postgres): pro Typ eine schlanke
+ /// {Id,Name}-Projektion laden und im Speicher per Präfix matchen — KEIN ::text-Cast
+ /// o.ä. Mehrdeutiges Präfix (mehrere Treffer) => id=null (nicht raten).
+ ///
+ public static class RefsEndpoints
+ {
+ /// Schlanke {Id,Name}-Projektion für den In-Memory-Präfix-Match.
+ private readonly record struct NamedRow(Guid Id, string Name);
+
+ public static IEndpointRouteBuilder MapRefsEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/refs").WithTags("Refs");
+
+ group.MapPost("/resolve", async Task>> (
+ ResolveRefsInput input, ApplicationContext db) =>
+ {
+ var refs = input.Refs ?? new List();
+
+ // Welche Entitätstypen werden überhaupt angefragt? Nur diese Tabellen laden.
+ var wantedTypes = refs
+ .Select(r => NormalizeType(r.Type))
+ .Where(t => t is not null)
+ .Distinct()
+ .ToHashSet();
+
+ // Pro Typ einmalig eine leichte {Id,Name}-Projektion laden (DB-agnostisch:
+ // reine Spaltenauswahl, kein ::text-Cast — läuft auf SQLite wie auf Postgres).
+ var byType = new Dictionary>();
+ if (wantedTypes.Contains("tier"))
+ byType["tier"] = await db.Gerbils.AsNoTracking()
+ .Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
+ if (wantedTypes.Contains("kontakt"))
+ byType["kontakt"] = await db.Contacts.AsNoTracking()
+ .Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
+ if (wantedTypes.Contains("wurf"))
+ byType["wurf"] = await db.Litters.AsNoTracking()
+ .Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
+ if (wantedTypes.Contains("gehege"))
+ byType["gehege"] = await db.Enclosures.AsNoTracking()
+ .Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
+
+ var results = new List(refs.Count);
+ foreach (var r in refs)
+ {
+ var type = NormalizeType(r.Type);
+ var prefix = NormalizeCode(r.Code);
+
+ if (type is null || prefix.Length < 6 || !byType.TryGetValue(type, out var rows))
+ {
+ results.Add(new RefResolution(r.Type, r.Code, null, null));
+ continue;
+ }
+
+ // Präfix-Match auf der bindestrich-losen, klein geschriebenen GUID ("N"-Format).
+ var matches = rows
+ .Where(x => x.Id.ToString("N").StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ .Take(2)
+ .ToList();
+
+ // Genau ein Treffer => auflösen; 0 (unbekannt) oder >1 (mehrdeutig) => null.
+ if (matches.Count == 1)
+ results.Add(new RefResolution(r.Type, r.Code, matches[0].Id, matches[0].Name));
+ else
+ results.Add(new RefResolution(r.Type, r.Code, null, null));
+ }
+
+ return TypedResults.Ok(results);
+ });
+
+ return app;
+ }
+
+ /// Akzeptiert die vier Typ-Schlüssel (case-insensitive); sonst null.
+ private static string? NormalizeType(string? type) => type?.Trim().ToLowerInvariant() switch
+ {
+ "tier" => "tier",
+ "kontakt" => "kontakt",
+ "wurf" => "wurf",
+ "gehege" => "gehege",
+ _ => null,
+ };
+
+ /// GUID-Code bindestrich-tolerant + Hex-only normalisieren (Kleinschreibung).
+ private static string NormalizeCode(string? code)
+ {
+ if (string.IsNullOrWhiteSpace(code)) return string.Empty;
+ var chars = code.Trim().ToLowerInvariant()
+ .Where(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'));
+ return new string(chars.ToArray());
+ }
+ }
+}
diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs
index 8b0667f..788f1e4 100644
--- a/GerbilManagerWebAPI/Program.cs
+++ b/GerbilManagerWebAPI/Program.cs
@@ -132,6 +132,7 @@ app.MapSaleReservationEndpoints();
app.MapWaitingListEndpoints();
app.MapReturnRecordEndpoints();
app.MapExhibitionEndpoints();
+app.MapRefsEndpoints();
app.Run();
diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts
index 0fbbb01..8d237ac 100644
--- a/gerbil-manager-web/e2e/mock-api.ts
+++ b/gerbil-manager-web/e2e/mock-api.ts
@@ -795,6 +795,34 @@ export async function installMockApi(page: Page): Promise {
return json(route, 405)
}
+ // REFS: getippte Kurz-Verweise (Shortlinks) auflösen. Spiegelt das Backend:
+ // Präfix-Match (>=6 Hex, bindestrich-tolerant) auf der bindestrich-losen Id;
+ // mehrdeutig/unbekannt => id/name null.
+ if (path === '/refs/resolve' && method === 'POST') {
+ const body = request.postDataJSON() as { refs?: { type?: string; code?: string }[] }
+ const tableFor: Record = {
+ tier: db.gerbils as unknown as Row[],
+ kontakt: db.contacts as unknown as Row[],
+ wurf: db.litters as unknown as Row[],
+ gehege: db.enclosures as unknown as Row[],
+ }
+ const normCode = (code: string) => (code ?? '').toLowerCase().replace(/[^0-9a-f]/g, '')
+ const results = (body.refs ?? []).map((r) => {
+ const type = (r.type ?? '').toLowerCase()
+ const prefix = normCode(r.code ?? '')
+ const rows = tableFor[type]
+ if (!rows || prefix.length < 6) return { type: r.type, code: r.code, id: null, name: null }
+ const matches = rows.filter((row) =>
+ String(row.id ?? '').toLowerCase().replace(/-/g, '').startsWith(prefix),
+ )
+ if (matches.length === 1) {
+ return { type: r.type, code: r.code, id: String(matches[0].id), name: String(matches[0].name ?? '') }
+ }
+ return { type: r.type, code: r.code, id: null, name: null }
+ })
+ return json(route, 200, results)
+ }
+
// Generische Kollektionen: / und //
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined
diff --git a/gerbil-manager-web/src/api/refs.ts b/gerbil-manager-web/src/api/refs.ts
new file mode 100644
index 0000000..542f9bb
--- /dev/null
+++ b/gerbil-manager-web/src/api/refs.ts
@@ -0,0 +1,51 @@
+/**
+ * REFS: API-Client für getippte Kurz-Verweise (Shortlinks, git-Stil) in Freitexten.
+ *
+ * Tokens im Text: `tier:`, `kontakt:`, `wurf:`, `gehege:` —
+ * `` ist ein GUID-Präfix (mind. 6 Hex) oder die volle GUID. Das Backend löst
+ * sie zu { id, name } auf (mehrdeutig/unbekannt => id=null); das Frontend rendert
+ * daraus Name + internen Link zur jeweiligen Detailseite.
+ */
+import { api } from './client'
+
+/** Die vier auflösbaren Entitätstypen (Token-Präfix vor dem ":"). */
+export type RefType = 'tier' | 'kontakt' | 'wurf' | 'gehege'
+
+export interface RefRequest {
+ type: RefType
+ code: string
+}
+
+export interface RefResolution {
+ type: string
+ code: string
+ /** Volle GUID oder null (unbekannt ODER mehrdeutig — nicht geraten). */
+ id: string | null
+ name: string | null
+}
+
+/** Routen-Präfix je Typ für den internen Link (siehe App.tsx). */
+export const REF_ROUTE: Record = {
+ tier: '/rennmaeuse',
+ kontakt: '/kontakte',
+ wurf: '/wuerfe',
+ gehege: '/gehege',
+}
+// Hinweis: Frontend-Routen weichen vom Token-Namen ab (wurf→/wuerfe), daher die Map.
+
+/** Token-Regex: `tier|kontakt|wurf|gehege` ":" gefolgt von >=6 Hex (oder voller GUID mit "-"). */
+export const REF_TOKEN_RE = /\b(tier|kontakt|wurf|gehege):([0-9a-fA-F]{6,}(?:-[0-9a-fA-F-]+)?)/g
+
+/** Erste 8 Hex-Stellen (erster GUID-Block) als Kurz-ID — wie eine kurze Git-Commit-ID. */
+export function shortCode(id: string): string {
+ return id.replace(/-/g, '').slice(0, 8)
+}
+
+/** Vollständiges Token für die Zwischenablage, z. B. "tier:3c5b747b". */
+export function shortToken(type: RefType, id: string): string {
+ return `${type}:${shortCode(id)}`
+}
+
+export function resolveRefs(refs: RefRequest[]): Promise {
+ return api.post('/refs/resolve', { refs })
+}
diff --git a/gerbil-manager-web/src/pages/TicketsPage.tsx b/gerbil-manager-web/src/pages/TicketsPage.tsx
index 7f8b01e..c526a36 100644
--- a/gerbil-manager-web/src/pages/TicketsPage.tsx
+++ b/gerbil-manager-web/src/pages/TicketsPage.tsx
@@ -22,21 +22,32 @@ import {
type FeedbackTicket,
} from '../api/feedback'
import { getGerbil } from '../api/gerbils'
+import { REF_ROUTE, REF_TOKEN_RE, resolveRefs, type RefType } from '../api/refs'
import { useApi } from '../hooks/useApi'
import { useToast } from '../components/toast'
import './tickets.css'
+/** Aufgelöster Kurz-Verweis (Shortlink): null = unbekannt/mehrdeutig. */
+type ResolvedRef = { id: string; name: string } | null
+
/**
* Nachschlage-Kontext für Ticket-Text-Links, damit RichText nicht durch jede Ebene
* durchgereicht werden muss:
* - ticketIds: bekannte Ticket-IDs (nackte GUID → Link auf das Ticket).
* - gerbilNames: aufgelöste Tier-Namen je GerbilId (nackte GUID → Name + Link zur Akte).
- * Wert `undefined` = noch nicht geladen, `null` = nicht gefunden.
+ * - refNames: aufgelöste Kurz-Verweise je `typ:code` (die KI kann `tier:3c5b747b` schreiben
+ * → Name + Link). Wert `undefined` = noch nicht geladen, `null` = unbekannt/mehrdeutig.
*/
const TicketRefsContext = createContext<{
ticketIds: Set
gerbilNames: Record
-}>({ ticketIds: new Set(), gerbilNames: {} })
+ refNames: Record
+}>({ ticketIds: new Set(), gerbilNames: {}, refNames: {} })
+
+/** Cache-Schlüssel für einen Kurz-Verweis. */
+function refKey(type: string, code: string): string {
+ return `${type.toLowerCase()}:${code.replace(/-/g, '').toLowerCase()}`
+}
const t = de.feedback.tickets
const ctx = de.feedback.contexts
@@ -112,11 +123,13 @@ function TextWithBreaks({ value }: { value: string }) {
)
}
-/** Inline-Markdown: **fett**, `code`, [Label](url)-Links, Tier-/Ticket-IDs, Zeilenumbrüche. */
+/** Inline-Markdown: **fett**, `code`, [Label](url)-Links, Kurz-Verweise (tier:…), IDs, Umbrüche. */
function InlineMarkdown({ text }: { text: string }) {
- const { ticketIds, gerbilNames } = useContext(TicketRefsContext)
+ const { ticketIds, gerbilNames, refNames } = useContext(TicketRefsContext)
+ // Gruppen: 1/2 = Markdown-Link, 3 = fett, 4 = code, 5/6 = Kurz-Verweis (typ/code), 7 = nackte GUID.
const re = new RegExp(
- `\\[([^\\]]+)\\]\\(([^)]+)\\)|\\*\\*([^*]+)\\*\\*|\`([^\`]+)\`|(${GUID_RE})`,
+ `\\[([^\\]]+)\\]\\(([^)]+)\\)|\\*\\*([^*]+)\\*\\*|\`([^\`]+)\`|` +
+ `\\b(tier|kontakt|wurf|gehege):([0-9a-fA-F]{6,}(?:-[0-9a-fA-F-]+)?)|(${GUID_RE})`,
'g',
)
const nodes: ReactNode[] = []
@@ -146,8 +159,21 @@ function InlineMarkdown({ text }: { text: string }) {
{m[4]}
,
)
+ } else if (m[5] !== undefined && m[6] !== undefined) {
+ // Kurz-Verweis tier:/kontakt:/wurf:/gehege: → aufgelöster Name + Link, sonst Roh-Text.
+ const type = m[5] as RefType
+ const resolved = refNames[refKey(type, m[6])]
+ if (resolved) {
+ nodes.push(
+
+ {resolved.name}
+ ,
+ )
+ } else {
+ nodes.push()
+ }
} else {
- const guid = m[5]
+ const guid = m[7]
const gn = gerbilNames[guid]
if (ticketIds.has(guid)) {
nodes.push(
@@ -269,6 +295,8 @@ export default function TicketsPage() {
const ticketIds = useMemo(() => new Set((rows ?? []).map((r) => r.id)), [rows])
// Aufgelöste Tier-Namen je GerbilId (für nackte Tier-IDs im Text → Name + Link).
const [gerbilNames, setGerbilNames] = useState>({})
+ // Aufgelöste Kurz-Verweise (tier:/kontakt:/wurf:/gehege:) je `typ:code`.
+ const [refNames, setRefNames] = useState>({})
// Gruppierung + Zählung je gefilterter Ansicht (für die Badges an den Filter-Chips).
const counts = useMemo(() => {
@@ -343,6 +371,49 @@ export default function TicketsPage() {
}
}, [candidateGerbilIds, gerbilNames])
+ // Kurz-Verweise (tier:/kontakt:/wurf:/gehege:) in den sichtbaren Tickets sammeln und
+ // einmalig über POST /refs/resolve auflösen — so kann die KI im Text die Kurz-ID nutzen.
+ const candidateRefs = useMemo(() => {
+ const map = new Map()
+ const add = (txt?: string | null) => {
+ if (!txt) return
+ const re = new RegExp(REF_TOKEN_RE.source, REF_TOKEN_RE.flags)
+ let m: RegExpExecArray | null
+ while ((m = re.exec(txt)) !== null) {
+ const type = m[1] as RefType
+ map.set(refKey(type, m[2]), { type, code: m[2] })
+ }
+ }
+ for (const tk of visible) {
+ add(tk.message); add(tk.question); add(tk.answer); add(tk.fixNote)
+ for (const e of tk.thread) add(e.text)
+ }
+ return map
+ }, [visible])
+
+ useEffect(() => {
+ const missing = [...candidateRefs].filter(([key]) => !(key in refNames))
+ if (missing.length === 0) return
+ let cancelled = false
+ resolveRefs(missing.map(([, r]) => r))
+ .then((results) => {
+ if (cancelled) return
+ setRefNames((prev) => {
+ const next = { ...prev }
+ for (const r of results) {
+ next[refKey(r.type, r.code)] = r.id && r.name ? { id: r.id, name: r.name } : null
+ }
+ return next
+ })
+ })
+ .catch(() => {
+ /* Auflösung fehlgeschlagen — Tokens bleiben Roh-Text. */
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [candidateRefs, refNames])
+
const emptyText: Record = {
dialog: t.emptyDialog,
open: t.emptyOpen,
@@ -386,7 +457,7 @@ export default function TicketsPage() {
{visible.length === 0 ? (
{emptyText[activeView]}
) : (
-
+
{visible.map((ticket) => (