Add unit and feature tests for ticket processing and article management

- Implement Fake repositories and services for testing purposes.
- Create tests for Article API including creation, validation, and listing.
- Develop ProcessTicketJobFlowTest to validate ticket processing logic.
- Add QuickReplyAdminTest for creating and updating quick replies.
- Implement TicketAndArticleModelTest to ensure proper cascading deletes and credential encryption.
- Create TicketIngestionTest for ticket creation and job dispatching.
- Add TicketShowPageTest to verify rendering of quick replies and tool calls.
- Implement unit tests for ClassifierPromptBuilder, EmbeddingService, LlmJsonDecoder, QuickReplyResolver, SupportReplyService, TicketResultPayloadBuilder, TicketToolCallService, and ToolCallRequestValidator.
This commit is contained in:
SitiWeb
2026-04-30 02:10:15 +02:00
parent 39bdba2dfb
commit c94d3f85e8
36 changed files with 7445 additions and 467 deletions

View File

@@ -38,7 +38,8 @@
<div class="font-medium">3. De betekenis wordt omgezet naar een zoekcode</div>
<p class="text-sm text-slate-600">
De app maakt van de nette vraag een soort getallen-code. Die code beschrijft niet de exacte
woorden, maar waar de vraag over gaat. Daardoor kan "mail doet het niet" ook artikelen vinden
woorden, maar waar de vraag over gaat. Daardoor kan "mail doet het niet" ook artikelen
vinden
waarin "e-mail storing" staat.
</p>
</div>
@@ -67,7 +68,8 @@
<div class="font-medium">7. Er komt een advies of een melding</div>
<p class="text-sm text-slate-600">
Als het artikel een snelantwoord heeft, gebruikt de app dat direct. Anders maakt de AI een
korte conceptreactie. Als er geen passend artikel is, schrijft de app geen klantantwoord maar
korte conceptreactie. Als er geen passend artikel is, schrijft de app geen klantantwoord
maar
meldt hij dat de kennisbank iets mist.
</p>
</div>
@@ -147,13 +149,15 @@
<h3 class="font-semibold">Hulpmiddelen en allowed actions</h3>
<p class="mt-2 text-sm text-slate-600">
Sommige antwoorden worden beter als de app iets kan nakijken. Daarom kan een artikel aangeven welke
hulpmiddelen toegestaan zijn. De AI mag zo'n hulpmiddel alleen voorstellen; de applicatie controleert
hulpmiddelen toegestaan zijn. De AI mag zo'n hulpmiddel alleen voorstellen; de applicatie
controleert
daarna zelf of het echt mag.
</p>
<div class="mt-4 rounded border p-3">
<div class="font-medium">Nu beschikbaar: domain_inf</div>
<p class="mt-1 text-sm text-slate-600">
Hiermee kan de app domeininformatie ophalen. Dat kan alleen als het artikel `domain_inf` toestaat,
Hiermee kan de app domeininformatie ophalen. Dat kan alleen als het artikel `domain_inf`
toestaat,
als de vraag een domeinnaam bevat, en als er API-gegevens op het ticket zijn opgeslagen.
</p>
</div>
@@ -164,9 +168,12 @@
<div class="mt-4 space-y-3 text-sm text-slate-600">
<div><span class="font-medium text-slate-900">Embedding:</span> een betekenis-code van tekst.</div>
<div><span class="font-medium text-slate-900">Chunk:</span> een klein stukje van een artikel.</div>
<div><span class="font-medium text-slate-900">Confidence:</span> hoe zeker de AI is van zijn keuze.</div>
<div><span class="font-medium text-slate-900">Knowledge gap:</span> de kennisbank heeft waarschijnlijk nog geen goed artikel voor deze vraag.</div>
<div><span class="font-medium text-slate-900">Toolcall:</span> een gecontroleerde actie waarmee de app extra informatie kan ophalen.</div>
<div><span class="font-medium text-slate-900">Confidence:</span> hoe zeker de AI is van zijn keuze.
</div>
<div><span class="font-medium text-slate-900">Knowledge gap:</span> de kennisbank heeft
waarschijnlijk nog geen goed artikel voor deze vraag.</div>
<div><span class="font-medium text-slate-900">Toolcall:</span> een gecontroleerde actie waarmee de
app extra informatie kan ophalen.</div>
</div>
</div>
</section>
@@ -180,7 +187,8 @@
</div>
<div class="rounded border p-3">
<div class="font-medium">App zoekt</div>
<p class="mt-1 text-sm text-slate-600">De app zoekt kaartjes over mail, domeinen en instellingen.</p>
<p class="mt-1 text-sm text-slate-600">De app zoekt kaartjes over mail, domeinen en instellingen.
</p>
</div>
<div class="rounded border p-3">
<div class="font-medium">AI kiest</div>
@@ -188,7 +196,8 @@
</div>
<div class="rounded border p-3">
<div class="font-medium">Support controleert</div>
<p class="mt-1 text-sm text-slate-600">Support ziet het advies, de reden en de gebruikte stappen.</p>
<p class="mt-1 text-sm text-slate-600">Support ziet het advies, de reden en de gebruikte stappen.
</p>
</div>
</div>
</section>

View File

@@ -13,11 +13,11 @@
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-medium">{{ $title }}</div>
@if($description)
@if ($description)
<p class="mt-1 text-sm text-slate-600">{{ $description }}</p>
@endif
</div>
@if($badge)
@if ($badge)
<span class="shrink-0 rounded bg-slate-100 px-2 py-1 text-xs text-slate-600">{{ $badge }}</span>
@endif
</div>

View File

@@ -1,5 +1,6 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -7,26 +8,28 @@
<script src="https://cdn.tailwindcss.com"></script>
@livewireStyles
</head>
<body class="bg-slate-100 text-slate-900">
<div class="min-h-screen">
<header class="bg-slate-900 text-white">
<div class="mx-auto max-w-7xl px-6 py-4 flex items-center justify-between">
<h1 class="text-lg font-semibold">Ticket Assistant Admin</h1>
<nav class="flex gap-3 text-sm">
<a href="{{ route('admin.dashboard') }}" class="hover:underline">Dashboard</a>
<a href="{{ route('admin.articles') }}" class="hover:underline">Articles</a>
<a href="{{ route('admin.quick-replies') }}" class="hover:underline">Snelantwoorden</a>
<a href="{{ route('admin.tickets') }}" class="hover:underline">Tickets</a>
<a href="{{ route('admin.process') }}" class="hover:underline">Proces</a>
<a href="{{ route('admin.settings') }}" class="hover:underline">Settings</a>
</nav>
</div>
</header>
<main class="mx-auto max-w-7xl px-6 py-6">
{{ $slot }}
</main>
</div>
@livewireScripts
<body class="bg-slate-100 text-slate-900">
<div class="min-h-screen">
<header class="bg-slate-900 text-white">
<div class="mx-auto max-w-7xl px-6 py-4 flex items-center justify-between">
<h1 class="text-lg font-semibold">Ticket Assistant Admin</h1>
<nav class="flex gap-3 text-sm">
<a href="{{ route('admin.dashboard') }}" class="hover:underline">Dashboard</a>
<a href="{{ route('admin.articles') }}" class="hover:underline">Articles</a>
<a href="{{ route('admin.quick-replies') }}" class="hover:underline">Snelantwoorden</a>
<a href="{{ route('admin.tickets') }}" class="hover:underline">Tickets</a>
<a href="{{ route('admin.process') }}" class="hover:underline">Proces</a>
<a href="{{ route('admin.settings') }}" class="hover:underline">Settings</a>
</nav>
</div>
</header>
<main class="mx-auto max-w-7xl px-6 py-6">
{{ $slot }}
</main>
</div>
@livewireScripts
</body>
</html>

View File

@@ -6,19 +6,30 @@
@endif
<form wire:submit="save" class="space-y-3">
<input wire:model="title" type="text" class="w-full border rounded p-2" placeholder="Titel">
@error('title') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
@error('title')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<textarea wire:model="content" class="w-full border rounded p-2 min-h-40" placeholder="Content"></textarea>
@error('content') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<textarea wire:model="note" class="w-full border rounded p-2 min-h-24" placeholder="Interne notitie voor de LLM (niet gebruikt voor embeddings)"></textarea>
@error('note') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
@error('content')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<textarea wire:model="note" class="w-full border rounded p-2 min-h-24"
placeholder="Interne notitie voor de LLM (niet gebruikt voor embeddings)"></textarea>
@error('note')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<label class="flex items-start gap-2 text-sm">
<input wire:model="allowedActions" type="checkbox" value="domain_inf" class="mt-1 rounded border-slate-300">
<input wire:model="allowedActions" type="checkbox" value="domain_inf"
class="mt-1 rounded border-slate-300">
<span>
<span class="font-medium">domain_inf toestaan</span>
<span class="block text-slate-500">Alleen uitvoeren wanneer de LLM deze action aanvraagt en sld/tld aanwezig zijn.</span>
<span class="block text-slate-500">Alleen uitvoeren wanneer de LLM deze action aanvraagt en sld/tld
aanwezig zijn.</span>
</span>
</label>
@error('allowedActions.*') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
@error('allowedActions.*')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<button class="bg-slate-900 text-white px-4 py-2 rounded" type="submit">Opslaan</button>
</form>
</div>
@@ -26,83 +37,84 @@
<div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-3">Artikelen</h2>
<div class="space-y-3">
@foreach($articles as $article)
@foreach ($articles as $article)
<div class="border rounded p-3">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-medium">#{{ $article->id }} {{ $article->title }}</div>
@if(($article->status ?? 'published') === 'draft')
<span class="inline-block mt-1 text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-800">Concept (AI)</span>
@if (($article->status ?? 'published') === 'draft')
<span
class="inline-block mt-1 text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-800">Concept
(AI)</span>
@endif
</div>
<div class="flex items-center gap-3">
@if(($article->status ?? 'published') === 'draft')
<button
type="button"
wire:click="approveDraft({{ $article->id }})"
class="text-sm text-green-700 hover:underline"
>
@if (($article->status ?? 'published') === 'draft')
<button type="button" wire:click="approveDraft({{ $article->id }})"
class="text-sm text-green-700 hover:underline">
Valideren & publiceren
</button>
@endif
<button
type="button"
wire:click="deleteArticle({{ $article->id }})"
<button type="button" wire:click="deleteArticle({{ $article->id }})"
wire:confirm="Weet je zeker dat je dit artikel wilt verwijderen?"
class="text-sm text-red-600 hover:underline"
>
class="text-sm text-red-600 hover:underline">
Verwijderen
</button>
</div>
</div>
@if($article->note)
@if ($article->note)
<div class="mt-2 text-xs rounded bg-slate-50 p-2 text-slate-600">
<span class="font-semibold">LLM note:</span> {{ \Illuminate\Support\Str::limit($article->note, 180) }}
<span class="font-semibold">LLM note:</span>
{{ \Illuminate\Support\Str::limit($article->note, 180) }}
</div>
@endif
@if(($article->allowed_actions ?? []) !== [])
@if (($article->allowed_actions ?? []) !== [])
<div class="mt-2 flex flex-wrap gap-1">
@foreach($article->allowed_actions as $action)
<span class="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-800">{{ $action }}</span>
@foreach ($article->allowed_actions as $action)
<span
class="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-800">{{ $action }}</span>
@endforeach
</div>
@endif
<div class="text-sm text-slate-600">{{ \Illuminate\Support\Str::limit($article->content, 140) }}</div>
<div class="text-sm text-slate-600">{{ \Illuminate\Support\Str::limit($article->content, 140) }}
</div>
<div class="mt-3 rounded bg-slate-50 p-3 space-y-2">
<label class="block text-xs font-semibold text-slate-500">LLM note</label>
<textarea wire:model="articleNotes.{{ $article->id }}" class="w-full border rounded p-2 min-h-20 text-sm" placeholder="Interne aanwijzingen voor dit artikel"></textarea>
@error("articleNotes.{$article->id}") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<textarea wire:model="articleNotes.{{ $article->id }}" class="w-full border rounded p-2 min-h-20 text-sm"
placeholder="Interne aanwijzingen voor dit artikel"></textarea>
@error("articleNotes.{$article->id}")
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<label class="flex items-start gap-2 text-sm">
<input wire:model="articleAllowedActions.{{ $article->id }}" type="checkbox" value="domain_inf" class="mt-1 rounded border-slate-300">
<input wire:model="articleAllowedActions.{{ $article->id }}" type="checkbox"
value="domain_inf" class="mt-1 rounded border-slate-300">
<span>domain_inf toestaan</span>
</label>
@error("articleAllowedActions.{$article->id}.*") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
@error("articleAllowedActions.{$article->id}.*")
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<div>
<label class="block text-xs font-semibold text-slate-500 mb-1">Gekoppelde snelantwoorden</label>
@if($quickReplyOptions->isEmpty())
<label class="block text-xs font-semibold text-slate-500 mb-1">Gekoppelde
snelantwoorden</label>
@if ($quickReplyOptions->isEmpty())
<p class="text-xs text-slate-500">Nog geen actieve snelantwoorden beschikbaar.</p>
@else
<div class="grid gap-1 sm:grid-cols-2">
@foreach($quickReplyOptions as $quickReply)
@foreach ($quickReplyOptions as $quickReply)
<label class="flex items-center gap-2 text-sm">
<input
wire:model="articleQuickReplies.{{ $article->id }}"
type="checkbox"
value="{{ $quickReply->id }}"
class="rounded border-slate-300"
>
<input wire:model="articleQuickReplies.{{ $article->id }}" type="checkbox"
value="{{ $quickReply->id }}" class="rounded border-slate-300">
<span>{{ $quickReply->title }}</span>
</label>
@endforeach
</div>
@endif
@error("articleQuickReplies.{$article->id}.*") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
@error("articleQuickReplies.{$article->id}.*")
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
</div>
<button
type="button"
wire:click="saveMetadata({{ $article->id }})"
class="text-sm px-3 py-1 rounded bg-slate-900 text-white"
>
<button type="button" wire:click="saveMetadata({{ $article->id }})"
class="text-sm px-3 py-1 rounded bg-slate-900 text-white">
Metadata opslaan
</button>
</div>

View File

@@ -1,9 +1,14 @@
<div class="space-y-6">
<div class="grid gap-4 md:grid-cols-4">
<div class="bg-white rounded-xl p-4 shadow">Articles<br><span class="text-2xl font-bold">{{ $stats['articles_count'] ?? 0 }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">Tickets<br><span class="text-2xl font-bold">{{ $stats['tickets_count'] ?? 0 }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">AI Decisions<br><span class="text-2xl font-bold">{{ $stats['decisions_count'] ?? 0 }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">Feedback Accuracy<br><span class="text-2xl font-bold">{{ isset($stats['feedback_accuracy']) ? $stats['feedback_accuracy'].'%' : 'N/A' }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">Articles<br><span
class="text-2xl font-bold">{{ $stats['articles_count'] ?? 0 }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">Tickets<br><span
class="text-2xl font-bold">{{ $stats['tickets_count'] ?? 0 }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">AI Decisions<br><span
class="text-2xl font-bold">{{ $stats['decisions_count'] ?? 0 }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">Feedback Accuracy<br><span
class="text-2xl font-bold">{{ isset($stats['feedback_accuracy']) ? $stats['feedback_accuracy'] . '%' : 'N/A' }}</span>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-2">
@@ -11,7 +16,8 @@
<h2 class="font-semibold mb-3">Recent Tickets</h2>
<ul class="space-y-2 text-sm">
@forelse($recentTickets as $ticket)
<li class="border-b pb-2">#{{ $ticket->id }} - {{ \Illuminate\Support\Str::limit($ticket->message, 100) }}</li>
<li class="border-b pb-2">#{{ $ticket->id }} -
{{ \Illuminate\Support\Str::limit($ticket->message, 100) }}</li>
@empty
<li>Geen tickets.</li>
@endforelse

View File

@@ -12,10 +12,14 @@
<form wire:submit="save" class="space-y-3">
<input wire:model="title" type="text" class="w-full border rounded p-2" placeholder="Titel">
@error('title') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
@error('title')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<textarea wire:model="content" class="w-full border rounded p-2 min-h-36" placeholder="Snelantwoord tekst"></textarea>
@error('content') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
@error('content')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<label class="flex items-center gap-2 text-sm">
<input wire:model="isActive" type="checkbox" class="rounded border-slate-300">
@@ -29,7 +33,7 @@
<div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-3">Snelantwoorden</h2>
<div class="space-y-3">
@foreach($quickReplies as $quickReply)
@foreach ($quickReplies as $quickReply)
<div class="border rounded p-3">
<div class="flex items-start justify-between gap-3">
<div>
@@ -38,33 +42,33 @@
Gekoppeld aan {{ $quickReply->articles_count }} artikel(en)
</div>
</div>
<button
type="button"
wire:click="deleteQuickReply({{ $quickReply->id }})"
<button type="button" wire:click="deleteQuickReply({{ $quickReply->id }})"
wire:confirm="Weet je zeker dat je dit snelantwoord wilt verwijderen?"
class="text-sm text-red-600 hover:underline"
>
class="text-sm text-red-600 hover:underline">
Verwijderen
</button>
</div>
<div class="mt-3 rounded bg-slate-50 p-3 space-y-2">
<input wire:model="editRows.{{ $quickReply->id }}.title" type="text" class="w-full border rounded p-2 text-sm">
@error("editRows.{$quickReply->id}.title") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<input wire:model="editRows.{{ $quickReply->id }}.title" type="text"
class="w-full border rounded p-2 text-sm">
@error("editRows.{$quickReply->id}.title")
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<textarea wire:model="editRows.{{ $quickReply->id }}.content" class="w-full border rounded p-2 min-h-28 text-sm"></textarea>
@error("editRows.{$quickReply->id}.content") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
@error("editRows.{$quickReply->id}.content")
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<label class="flex items-center gap-2 text-sm">
<input wire:model="editRows.{{ $quickReply->id }}.is_active" type="checkbox" class="rounded border-slate-300">
<input wire:model="editRows.{{ $quickReply->id }}.is_active" type="checkbox"
class="rounded border-slate-300">
<span>Actief</span>
</label>
<button
type="button"
wire:click="updateQuickReply({{ $quickReply->id }})"
class="text-sm px-3 py-1 rounded bg-slate-900 text-white"
>
<button type="button" wire:click="updateQuickReply({{ $quickReply->id }})"
class="text-sm px-3 py-1 rounded bg-slate-900 text-white">
Wijzigingen opslaan
</button>
</div>

View File

@@ -15,12 +15,9 @@
@endif
<div class="mt-4 flex flex-wrap gap-2 text-sm">
@foreach(['process' => 'Proces & prompts', 'providers' => 'Providers', 'models' => 'Modellen', 'embeddings' => 'Embeddings'] as $tab => $label)
<button
type="button"
wire:click="setTab('{{ $tab }}')"
class="rounded px-3 py-2 {{ $activeTab === $tab ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700' }}"
>
@foreach (['process' => 'Proces & prompts', 'providers' => 'Providers', 'models' => 'Modellen', 'embeddings' => 'Embeddings'] as $tab => $label)
<button type="button" wire:click="setTab('{{ $tab }}')"
class="rounded px-3 py-2 {{ $activeTab === $tab ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700' }}">
{{ $label }}
</button>
@endforeach
@@ -28,11 +25,12 @@
</div>
<form id="ai-settings-form" wire:submit="save" class="space-y-4">
@if($activeTab === 'process')
@if ($activeTab === 'process')
<section class="bg-white rounded-xl p-4 shadow space-y-4">
<div>
<h3 class="font-semibold">Proces & prompts</h3>
<p class="text-sm text-slate-600">Elke stap toont wat er gebeurt. Waar een prompt gebruikt wordt, kun je die hier aanpassen.</p>
<p class="text-sm text-slate-600">Elke stap toont wat er gebeurt. Waar een prompt gebruikt wordt,
kun je die hier aanpassen.</p>
</div>
<div>
@@ -44,20 +42,15 @@
</div>
<div class="space-y-3">
@foreach($processSteps as $step)
<x-admin.timeline-card
:number="$step['number']"
:title="$step['title']"
:description="$step['description']"
:badge="isset($step['prompt_key']) ? 'Prompt' : 'Geen prompt'"
>
@if(isset($step['prompt_key']))
<label class="block text-xs font-medium text-slate-500">{{ $step['prompt_key'] }}</label>
<textarea
wire:model="promptValues.{{ $step['id'] }}"
class="w-full border rounded p-2 min-h-28 text-sm"
></textarea>
@error('promptValues.'.$step['id']) <p class="text-sm text-red-600">{{ $message }}</p> @enderror
@foreach ($processSteps as $step)
<x-admin.timeline-card :number="$step['number']" :title="$step['title']" :description="$step['description']" :badge="isset($step['prompt_key']) ? 'Prompt' : 'Geen prompt'">
@if (isset($step['prompt_key']))
<label
class="block text-xs font-medium text-slate-500">{{ $step['prompt_key'] }}</label>
<textarea wire:model="promptValues.{{ $step['id'] }}" class="w-full border rounded p-2 min-h-28 text-sm"></textarea>
@error('promptValues.' . $step['id'])
<p class="text-sm text-red-600">{{ $message }}</p>
@enderror
@endif
</x-admin.timeline-card>
@endforeach
@@ -65,14 +58,16 @@
</section>
@endif
@if($activeTab === 'providers')
@if ($activeTab === 'providers')
<section class="bg-white rounded-xl p-4 shadow space-y-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 class="font-semibold">LLM provider instances</h3>
<p class="text-sm text-slate-600">Voeg meerdere Ollama- of LM Studio-instances toe en kies welke instance actief is.</p>
<p class="text-sm text-slate-600">Voeg meerdere Ollama- of LM Studio-instances toe en kies welke
instance actief is.</p>
</div>
<button type="button" wire:click="addProviderInstance" class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
<button type="button" wire:click="addProviderInstance"
class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
Instance toevoegen
</button>
</div>
@@ -80,20 +75,24 @@
<div class="grid gap-3 md:grid-cols-2">
<div>
<label class="block text-sm mb-1">Actieve instance</label>
<select wire:model.live="activeProviderInstanceId" wire:change="loadModels" class="w-full border rounded p-2">
@foreach($providerInstances as $instance)
<option value="{{ $instance['id'] }}">{{ $instance['name'] }} ({{ $providerDefinitions[$instance['type']]['label'] ?? $instance['type'] }})</option>
<select wire:model.live="activeProviderInstanceId" wire:change="loadModels"
class="w-full border rounded p-2">
@foreach ($providerInstances as $instance)
<option value="{{ $instance['id'] }}">{{ $instance['name'] }}
({{ $providerDefinitions[$instance['type']]['label'] ?? $instance['type'] }})
</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm mb-1">Timeout seconden</label>
<input wire:model="llm_timeout" type="number" min="5" max="600" class="w-full border rounded p-2">
<input wire:model="llm_timeout" type="number" min="5" max="600"
class="w-full border rounded p-2">
</div>
</div>
<div class="space-y-4">
@foreach($providerInstances as $index => $instance)
@foreach ($providerInstances as $index => $instance)
@php($type = $instance['type'] ?? 'lmstudio')
@php($definition = $providerDefinitions[$type] ?? ['label' => $type, 'description' => ''])
<div class="border rounded p-4 space-y-3">
@@ -103,18 +102,18 @@
<p class="text-sm text-slate-600">{{ $definition['description'] }}</p>
</div>
<div class="flex items-center gap-2">
@if($activeProviderInstanceId === ($instance['id'] ?? null))
<span class="rounded bg-green-100 px-2 py-1 text-xs text-green-700">Actief</span>
@if ($activeProviderInstanceId === ($instance['id'] ?? null))
<span
class="rounded bg-green-100 px-2 py-1 text-xs text-green-700">Actief</span>
@else
<button type="button" wire:click="setActiveProviderInstance('{{ $instance['id'] }}')" class="rounded bg-slate-100 px-2 py-1 text-xs text-slate-700">
<button type="button"
wire:click="setActiveProviderInstance('{{ $instance['id'] }}')"
class="rounded bg-slate-100 px-2 py-1 text-xs text-slate-700">
Actief maken
</button>
@endif
<button
type="button"
wire:click="removeProviderInstance('{{ $instance['id'] }}')"
class="text-xs text-red-600 hover:underline"
>
<button type="button" wire:click="removeProviderInstance('{{ $instance['id'] }}')"
class="text-xs text-red-600 hover:underline">
Verwijderen
</button>
</div>
@@ -123,13 +122,16 @@
<div class="grid gap-3 md:grid-cols-2">
<div>
<label class="block text-sm mb-1">Naam</label>
<input wire:model="providerInstances.{{ $index }}.name" type="text" class="w-full border rounded p-2">
<input wire:model="providerInstances.{{ $index }}.name" type="text"
class="w-full border rounded p-2">
</div>
<div>
<label class="block text-sm mb-1">Type</label>
<select wire:model="providerInstances.{{ $index }}.type" class="w-full border rounded p-2">
@foreach($providerDefinitions as $provider => $providerDefinition)
<option value="{{ $provider }}">{{ $providerDefinition['label'] }}</option>
<select wire:model="providerInstances.{{ $index }}.type"
class="w-full border rounded p-2">
@foreach ($providerDefinitions as $provider => $providerDefinition)
<option value="{{ $provider }}">{{ $providerDefinition['label'] }}
</option>
@endforeach
</select>
</div>
@@ -137,16 +139,19 @@
<div>
<label class="block text-sm mb-1">Base URL</label>
<input wire:model="providerInstances.{{ $index }}.base_url" type="url" class="w-full border rounded p-2">
<input wire:model="providerInstances.{{ $index }}.base_url" type="url"
class="w-full border rounded p-2">
</div>
<div class="grid gap-3 md:grid-cols-2">
<div>
<label class="block text-sm mb-1">Standaard chat model</label>
<input wire:model="providerInstances.{{ $index }}.chat_model" type="text" class="w-full border rounded p-2">
<input wire:model="providerInstances.{{ $index }}.chat_model" type="text"
class="w-full border rounded p-2">
</div>
<div>
<label class="block text-sm mb-1">Standaard embedding model</label>
<input wire:model="providerInstances.{{ $index }}.embedding_model" type="text" class="w-full border rounded p-2">
<input wire:model="providerInstances.{{ $index }}.embedding_model"
type="text" class="w-full border rounded p-2">
</div>
</div>
</div>
@@ -155,25 +160,28 @@
</section>
@endif
@if($activeTab === 'models')
@if ($activeTab === 'models')
<section class="bg-white rounded-xl p-4 shadow space-y-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 class="font-semibold">Modellen per stap</h3>
<p class="text-sm text-slate-600">Kies per stap een model van de actieve instance. De lijst wordt 5 minuten gecachet.</p>
<p class="text-sm text-slate-600">Kies per stap een model van de actieve instance. De lijst
wordt 5 minuten gecachet.</p>
</div>
<button type="button" wire:click="refreshModels" class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
<button type="button" wire:click="refreshModels"
class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
Modellen verversen
</button>
</div>
@if($modelLoadError)
@if ($modelLoadError)
<div class="rounded bg-amber-100 p-2 text-sm text-amber-800">
Modellen konden niet live worden opgehaald: {{ $modelLoadError }}
</div>
@elseif($availableModels === [])
<div class="rounded bg-slate-100 p-2 text-sm text-slate-700">
Geen modellen gevonden voor de actieve instance. Je kunt nog steeds handmatig een modelnaam invullen.
Geen modellen gevonden voor de actieve instance. Je kunt nog steeds handmatig een modelnaam
invullen.
</div>
@else
<div class="rounded bg-green-100 p-2 text-sm text-green-800">
@@ -183,51 +191,48 @@
<div class="rounded border p-3 text-sm text-slate-600">
Actieve instance:
@foreach($providerInstances as $instance)
@if(($instance['id'] ?? null) === $activeProviderInstanceId)
<strong>{{ $instance['name'] }}</strong> ({{ $providerDefinitions[$instance['type']]['label'] ?? $instance['type'] }})
@foreach ($providerInstances as $instance)
@if (($instance['id'] ?? null) === $activeProviderInstanceId)
<strong>{{ $instance['name'] }}</strong>
({{ $providerDefinitions[$instance['type']]['label'] ?? $instance['type'] }})
@endif
@endforeach
</div>
<div class="space-y-3">
@foreach($modelTasks as $task)
<x-admin.timeline-card
:number="$task['number']"
:title="$task['title']"
:description="$task['description']"
badge="Model"
>
@if($availableModels !== [])
<select wire:model="modelValues.{{ $task['id'] }}" class="w-full border rounded p-2 text-sm">
@foreach ($modelTasks as $task)
<x-admin.timeline-card :number="$task['number']" :title="$task['title']" :description="$task['description']" badge="Model">
@if ($availableModels !== [])
<select wire:model="modelValues.{{ $task['id'] }}"
class="w-full border rounded p-2 text-sm">
<option value="">Kies model</option>
@foreach($availableModels as $model)
@foreach ($availableModels as $model)
<option value="{{ $model }}">{{ $model }}</option>
@endforeach
</select>
@else
<input
wire:model="modelValues.{{ $task['id'] }}"
type="text"
class="w-full border rounded p-2 text-sm"
placeholder="Modelnaam"
>
<input wire:model="modelValues.{{ $task['id'] }}" type="text"
class="w-full border rounded p-2 text-sm" placeholder="Modelnaam">
@endif
@error('modelValues.'.$task['id']) <p class="text-sm text-red-600">{{ $message }}</p> @enderror
@error('modelValues.' . $task['id'])
<p class="text-sm text-red-600">{{ $message }}</p>
@enderror
</x-admin.timeline-card>
@endforeach
</div>
</section>
@endif
@if($activeTab === 'embeddings')
@if ($activeTab === 'embeddings')
<section class="bg-white rounded-xl p-4 shadow space-y-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 class="font-semibold">Chunk embeddings</h3>
<p class="text-sm text-slate-600">Hergenereer embeddings voor kennisbankchunks. Dit draait via de queue en gebruikt de actieve embedding-provider en het embeddingmodel.</p>
<p class="text-sm text-slate-600">Hergenereer embeddings voor kennisbankchunks. Dit draait via
de queue en gebruikt de actieve embedding-provider en het embeddingmodel.</p>
</div>
<button type="button" wire:click="refreshEmbeddingStats" class="rounded bg-slate-100 px-3 py-2 text-sm text-slate-700">
<button type="button" wire:click="refreshEmbeddingStats"
class="rounded bg-slate-100 px-3 py-2 text-sm text-slate-700">
Status verversen
</button>
</div>
@@ -236,17 +241,21 @@
<div class="rounded border p-3">
<div class="text-xs text-slate-500">Artikelen</div>
<div class="mt-1 text-2xl font-semibold">{{ $embeddingStats['articles'] ?? 0 }}</div>
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['articles_without_chunks'] ?? 0 }} zonder chunks</p>
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['articles_without_chunks'] ?? 0 }}
zonder chunks</p>
</div>
<div class="rounded border p-3">
<div class="text-xs text-slate-500">Chunks</div>
<div class="mt-1 text-2xl font-semibold">{{ $embeddingStats['chunks'] ?? 0 }}</div>
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['chunks_without_embedding'] ?? 0 }} zonder embedding</p>
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['chunks_without_embedding'] ?? 0 }}
zonder embedding</p>
</div>
<div class="rounded border p-3">
<div class="text-xs text-slate-500">Chunks met embedding</div>
<div class="mt-1 text-2xl font-semibold">{{ $embeddingStats['chunks_with_embedding'] ?? 0 }}</div>
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['articles_with_chunks'] ?? 0 }} artikelen geindexeerd</p>
<div class="mt-1 text-2xl font-semibold">{{ $embeddingStats['chunks_with_embedding'] ?? 0 }}
</div>
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['articles_with_chunks'] ?? 0 }}
artikelen geindexeerd</p>
</div>
</div>
@@ -263,8 +272,10 @@
</div>
</div>
<p class="mt-2 text-slate-600">
{{ $embeddingStats['current_embedding_chunks'] ?? 0 }} chunks passen bij het actieve embeddingmodel.
{{ $embeddingStats['stale_or_other_model_chunks'] ?? 0 }} chunks zijn leeg, oud of voor een ander model.
{{ $embeddingStats['current_embedding_chunks'] ?? 0 }} chunks passen bij het actieve
embeddingmodel.
{{ $embeddingStats['stale_or_other_model_chunks'] ?? 0 }} chunks zijn leeg, oud of voor een
ander model.
</p>
</div>
@@ -272,9 +283,11 @@
<div class="border rounded p-4 space-y-3">
<div>
<h4 class="font-medium">Alleen ontbrekende chunks genereren</h4>
<p class="mt-1 text-sm text-slate-600">Plaats alleen artikelen zonder chunks opnieuw in de queue.</p>
<p class="mt-1 text-sm text-slate-600">Plaats alleen artikelen zonder chunks opnieuw in de
queue.</p>
</div>
<button type="button" wire:click="reindexMissingEmbeddings" class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
<button type="button" wire:click="reindexMissingEmbeddings"
class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
Ontbrekende chunks genereren
</button>
</div>
@@ -282,14 +295,12 @@
<div class="border rounded p-4 space-y-3">
<div>
<h4 class="font-medium">Alles opnieuw genereren</h4>
<p class="mt-1 text-sm text-slate-600">Plaats alle artikelen opnieuw in de queue. Bestaande chunks worden per artikel vervangen tijdens verwerking.</p>
<p class="mt-1 text-sm text-slate-600">Plaats alle artikelen opnieuw in de queue. Bestaande
chunks worden per artikel vervangen tijdens verwerking.</p>
</div>
<button
type="button"
wire:click="reindexAllEmbeddings"
<button type="button" wire:click="reindexAllEmbeddings"
wire:confirm="Weet je zeker dat je alle artikelchunks opnieuw wilt genereren?"
class="rounded bg-red-700 px-3 py-2 text-sm text-white"
>
class="rounded bg-red-700 px-3 py-2 text-sm text-white">
Alle chunks opnieuw genereren
</button>
</div>

View File

@@ -2,31 +2,42 @@
<div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-3">Ticket simulatie (handmatig inschieten)</h2>
@if($submitError)
@if ($submitError)
<div class="mb-3 rounded bg-red-100 text-red-700 px-3 py-2 text-sm">{{ $submitError }}</div>
@endif
@if($lastResult)
@if ($lastResult)
<div class="mb-3 rounded bg-green-100 text-green-800 px-3 py-2 text-sm">
Ticket #{{ $lastResult['ticket_id'] }} aangemaakt met status '{{ $lastResult['status'] }}'.
<a class="underline" href="{{ route('admin.tickets.show', ['ticket' => $lastResult['ticket_id']]) }}">Bekijk voortgang</a>
<a class="underline"
href="{{ route('admin.tickets.show', ['ticket' => $lastResult['ticket_id']]) }}">Bekijk voortgang</a>
</div>
@endif
<form wire:submit="submitTicket" class="space-y-3">
<textarea wire:model="newTicketMessage" class="w-full border rounded p-2 min-h-28" placeholder="Bijv: Mijn website geeft 500 fout na plugin update"></textarea>
@error('newTicketMessage') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<textarea wire:model="newTicketMessage" class="w-full border rounded p-2 min-h-28"
placeholder="Bijv: Mijn website geeft 500 fout na plugin update"></textarea>
@error('newTicketMessage')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<div class="grid md:grid-cols-2 gap-3">
<div>
<input wire:model="apiUser" type="text" class="w-full border rounded p-2" placeholder="API user (optioneel)">
@error('apiUser') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<input wire:model="apiUser" type="text" class="w-full border rounded p-2"
placeholder="API user (optioneel)">
@error('apiUser')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
</div>
<div>
<input wire:model="apiPassword" type="password" class="w-full border rounded p-2" placeholder="API password/key (optioneel)">
@error('apiPassword') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<input wire:model="apiPassword" type="password" class="w-full border rounded p-2"
placeholder="API password/key (optioneel)">
@error('apiPassword')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
</div>
</div>
<p class="text-xs text-slate-500">Credentials worden encrypted op het ticket opgeslagen en alleen gebruikt voor toegestane toolcalls.</p>
<p class="text-xs text-slate-500">Credentials worden encrypted op het ticket opgeslagen en alleen gebruikt
voor toegestane toolcalls.</p>
<button class="bg-slate-900 text-white px-4 py-2 rounded" type="submit">Ticket inschieten</button>
</form>
</div>
@@ -45,18 +56,20 @@
</div>
<div class="space-y-3">
@foreach($tickets as $ticket)
@foreach ($tickets as $ticket)
<div class="border rounded p-3">
<div class="flex items-center justify-between">
<div class="font-medium">Ticket #{{ $ticket->id }}</div>
<span class="text-xs px-2 py-1 rounded bg-slate-100">{{ $ticket->status }}</span>
</div>
<div class="text-sm text-slate-700 mb-2">{{ $ticket->message }}</div>
@if($ticket->bestArticle)
<div class="text-sm">Article: #{{ $ticket->bestArticle->id }} | Confidence: {{ number_format((float) $ticket->confidence, 2) }}</div>
@if ($ticket->bestArticle)
<div class="text-sm">Article: #{{ $ticket->bestArticle->id }} | Confidence:
{{ number_format((float) $ticket->confidence, 2) }}</div>
<div class="text-xs text-slate-500">{{ $ticket->explanation }}</div>
@endif
<a class="text-sm underline" href="{{ route('admin.tickets.show', ['ticket' => $ticket->id]) }}">Bekijk detail/progress</a>
<a class="text-sm underline"
href="{{ route('admin.tickets.show', ['ticket' => $ticket->id]) }}">Bekijk detail/progress</a>
</div>
@endforeach
</div>

View File

@@ -3,12 +3,9 @@
<div class="flex items-center justify-between">
<h2 class="font-semibold">Ticket #{{ $ticket->id }}</h2>
<div class="flex items-center gap-3">
<button
type="button"
wire:click="reprocess"
<button type="button" wire:click="reprocess"
wire:confirm="Weet je zeker dat je ticket #{{ $ticket->id }} opnieuw wilt verwerken?"
class="text-sm px-3 py-1 rounded bg-slate-900 text-white"
>
class="text-sm px-3 py-1 rounded bg-slate-900 text-white">
Herverwerk ticket
</button>
<span class="text-sm px-2 py-1 rounded bg-slate-100">Status: {{ $ticket->status }}</span>
@@ -19,14 +16,14 @@
<div class="mt-3 rounded bg-green-100 text-green-700 p-2 text-sm">{{ session('success') }}</div>
@endif
@if($ticket->needs_article_draft)
@if ($ticket->needs_article_draft)
<div class="mt-3 rounded bg-amber-50 border border-amber-300 p-3">
<div class="text-sm font-semibold text-amber-900 mb-1">Kennisbank-gat gedetecteerd</div>
<p class="text-sm text-amber-900">
Er is geen geschikt artikel in de kennisbank gevonden voor deze vraag.
</p>
@php($suggestion = $ticket->result_payload['draft_article_suggestion'] ?? null)
@if(is_array($suggestion))
@if (is_array($suggestion))
<div class="mt-3">
<p class="text-sm font-semibold text-amber-900">Voorgestelde titel</p>
<p class="text-sm text-amber-900">{{ $suggestion['title'] ?? '-' }}</p>
@@ -39,12 +36,13 @@
</div>
@endif
@if($ticket->support_reply && !$ticket->needs_article_draft)
@if ($ticket->support_reply && !$ticket->needs_article_draft)
<div class="mt-3 rounded bg-blue-50 border border-blue-200 p-3">
<div class="text-sm font-semibold text-blue-900 mb-1">Concept reactie voor klant</div>
@if(is_array($ticket->result_payload['quick_reply'] ?? null))
@if (is_array($ticket->result_payload['quick_reply'] ?? null))
<div class="mb-2 inline-flex rounded bg-blue-100 px-2 py-1 text-xs text-blue-900">
Snelantwoord gebruikt: #{{ $ticket->result_payload['quick_reply']['id'] ?? '-' }} {{ $ticket->result_payload['quick_reply']['title'] ?? '' }}
Snelantwoord gebruikt: #{{ $ticket->result_payload['quick_reply']['id'] ?? '-' }}
{{ $ticket->result_payload['quick_reply']['title'] ?? '' }}
</div>
@endif
<pre class="text-sm whitespace-pre-wrap text-blue-900">{{ $ticket->support_reply }}</pre>
@@ -52,24 +50,25 @@
@endif
<p class="mt-3 text-sm text-slate-800"><strong>Origineel:</strong> {{ $ticket->message }}</p>
@if($ticket->normalized_message)
<p class="mt-2 text-sm text-slate-800"><strong>Genormaliseerd:</strong> {{ $ticket->normalized_message }}</p>
@if ($ticket->normalized_message)
<p class="mt-2 text-sm text-slate-800"><strong>Genormaliseerd:</strong> {{ $ticket->normalized_message }}
</p>
@endif
@if($ticket->redaction_report)
<pre class="text-xs mt-2 bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($ticket->redaction_report, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}</pre>
@if ($ticket->redaction_report)
<pre class="text-xs mt-2 bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($ticket->redaction_report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
@endif
@if(is_array($ticket->api_credentials) && !empty($ticket->api_credentials['apiuser']))
@if (is_array($ticket->api_credentials) && !empty($ticket->api_credentials['apiuser']))
<div class="mt-3 text-sm rounded bg-slate-100 text-slate-700 p-2">
API credentials aanwezig voor deze ticket-run. Waarden worden niet getoond.
</div>
@endif
@if($ticket->error_message)
@if ($ticket->error_message)
<div class="mt-3 text-sm rounded bg-red-100 text-red-700 p-2">{{ $ticket->error_message }}</div>
@endif
@if($ticket->bestArticle)
@if ($ticket->bestArticle)
<div class="mt-3 text-sm rounded bg-green-100 text-green-800 p-2">
Beste artikel: #{{ $ticket->bestArticle->id }} - {{ $ticket->bestArticle->title }}
@if($ticket->confidence !== null)
@if ($ticket->confidence !== null)
(confidence {{ number_format($ticket->confidence, 2) }})
@endif
</div>
@@ -78,34 +77,35 @@
<div class="bg-white rounded-xl p-4 shadow">
<h3 class="font-semibold mb-3">Toolcalls</h3>
@if($ticket->toolCalls->isEmpty())
@if ($ticket->toolCalls->isEmpty())
<p class="text-sm text-slate-600">Geen toolcalls uitgevoerd of voorgesteld.</p>
@else
<div class="space-y-2">
@foreach($ticket->toolCalls as $toolCall)
@foreach ($ticket->toolCalls as $toolCall)
<div class="border rounded p-3 text-sm">
<div class="flex items-center justify-between">
<div class="font-medium">
{{ $toolCall->action }} ({{ $toolCall->status }})
@if($toolCall->article)
@if ($toolCall->article)
<span class="text-slate-500">via artikel #{{ $toolCall->article->id }}</span>
@endif
</div>
<span class="text-xs text-slate-500">{{ $toolCall->executed_at ?? $toolCall->created_at }}</span>
<span
class="text-xs text-slate-500">{{ $toolCall->executed_at ?? $toolCall->created_at }}</span>
</div>
@if($toolCall->parameters)
@if ($toolCall->parameters)
<div class="mt-2">
<div class="text-xs font-semibold text-slate-500">Parameters</div>
<pre class="text-xs bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($toolCall->parameters, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}</pre>
<pre class="text-xs bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($toolCall->parameters, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
</div>
@endif
@if($toolCall->response)
@if ($toolCall->response)
<div class="mt-2">
<div class="text-xs font-semibold text-slate-500">Response</div>
<pre class="text-xs bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($toolCall->response, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) }}</pre>
<pre class="text-xs bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($toolCall->response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) }}</pre>
</div>
@endif
@if($toolCall->error)
@if ($toolCall->error)
<div class="mt-2 rounded bg-amber-50 text-amber-900 p-2">{{ $toolCall->error }}</div>
@endif
</div>
@@ -118,8 +118,9 @@
<h3 class="font-semibold mb-3">Verwerkingsstappen</h3>
@php($orderedLogs = $ticket->logs->sortBy([['created_at', 'desc'], ['id', 'desc']]))
@php($latestLog = $orderedLogs->first())
@if($ticket->status === 'processing' && $latestLog)
<div class="mb-3 rounded border border-blue-200 bg-blue-50 p-2 text-sm text-blue-900 flex items-center justify-between">
@if ($ticket->status === 'processing' && $latestLog)
<div
class="mb-3 rounded border border-blue-200 bg-blue-50 p-2 text-sm text-blue-900 flex items-center justify-between">
<span>Huidige stap: <strong>{{ $latestLog->step }}</strong></span>
<span class="inline-flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-blue-600 animate-pulse"></span>
@@ -128,17 +129,17 @@
</div>
@endif
<div class="space-y-2">
@foreach($orderedLogs as $log)
@foreach ($orderedLogs as $log)
<div class="border rounded p-3 text-sm">
<div class="flex items-center justify-between">
<span class="font-medium">{{ $log->step }} ({{ $log->status }})</span>
<span class="text-xs text-slate-500">{{ $log->created_at }}</span>
</div>
@if($log->message)
@if ($log->message)
<div class="text-slate-700 mt-1">{{ $log->message }}</div>
@endif
@if($log->context)
<pre class="text-xs mt-2 bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($log->context, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}</pre>
@if ($log->context)
<pre class="text-xs mt-2 bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($log->context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
@endif
</div>
@endforeach

File diff suppressed because one or more lines are too long