feat(admin/ui): three-section merge plan UI + plan/apply proxy endpoints (D6)
This commit is contained in:
@@ -252,6 +252,30 @@ export interface FieldSuggestion {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface FieldMerge {
|
||||
field: string;
|
||||
current: unknown;
|
||||
suggested: unknown;
|
||||
confidence: 'high' | 'medium';
|
||||
reason: string;
|
||||
decision: 'auto_apply' | 'review' | 'rejected';
|
||||
decision_reason: string;
|
||||
validation?: 'ok' | 'warn' | 'fail';
|
||||
}
|
||||
|
||||
export interface MergePlan {
|
||||
auto_apply: FieldMerge[];
|
||||
review_required: FieldMerge[];
|
||||
rejected: FieldMerge[];
|
||||
cross_warnings: string[];
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
export interface PlanResponse {
|
||||
plan: MergePlan;
|
||||
research_result: ResearchResult;
|
||||
}
|
||||
|
||||
// Duplicate detection
|
||||
export interface DuplicateMarket {
|
||||
id: string;
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { ResearchResult, FieldSuggestion } from '$lib/api/types.js';
|
||||
import type { PlanResponse, FieldMerge } from '$lib/api/types.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
interface Props {
|
||||
result: ResearchResult;
|
||||
onApply: (suggestions: FieldSuggestion[]) => void;
|
||||
result: PlanResponse;
|
||||
onApply: (fields: string[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { result, onApply, onClose }: Props = $props();
|
||||
|
||||
let selected: boolean[] = $state(untrack(() => result.suggestions.map(() => true)));
|
||||
// auto_apply: all checked by default; review_required: none checked by default
|
||||
let autoSelected: boolean[] = $state(untrack(() => result.plan.auto_apply.map(() => true)));
|
||||
let reviewSelected: boolean[] = $state(
|
||||
untrack(() => result.plan.review_required.map(() => false))
|
||||
);
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
name: 'Name',
|
||||
@@ -74,10 +78,27 @@
|
||||
return (cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const chosen = result.suggestions.filter((_, i) => selected[i]);
|
||||
onApply(chosen);
|
||||
function badgeLabel(item: FieldMerge): string {
|
||||
const conf = item.confidence === 'high' ? 'Hoch' : 'Mittel';
|
||||
if (item.validation === 'ok') return `${conf} • Validierung OK`;
|
||||
if (item.validation === 'warn') return `${conf} • Warnung`;
|
||||
if (item.validation === 'fail') return `${conf} • Fehler`;
|
||||
return conf;
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const autoFields = result.plan.auto_apply
|
||||
.filter((_, i) => autoSelected[i])
|
||||
.map((item) => item.field);
|
||||
const reviewFields = result.plan.review_required
|
||||
.filter((_, i) => reviewSelected[i])
|
||||
.map((item) => item.field);
|
||||
onApply([...autoFields, ...reviewFields]);
|
||||
}
|
||||
|
||||
const totalSelectable = $derived(
|
||||
result.plan.auto_apply.length + result.plan.review_required.length
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -96,121 +117,328 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if result.suggestions.length === 0}
|
||||
<p class="text-sm text-stone-600 dark:text-stone-400">Keine Vorschläge gefunden.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each result.suggestions as suggestion, i}
|
||||
<label
|
||||
class="flex cursor-pointer gap-3 rounded-lg border border-stone-200 p-3 transition-colors hover:bg-stone-100 dark:border-stone-700 dark:hover:bg-stone-800"
|
||||
>
|
||||
<input type="checkbox" bind:checked={selected[i]} class="mt-1" />
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-stone-800 dark:text-stone-100">
|
||||
{fieldLabels[suggestion.field] ?? suggestion.field}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {confidenceColors[
|
||||
suggestion.confidence
|
||||
]}"
|
||||
>
|
||||
{suggestion.confidence}
|
||||
</span>
|
||||
</div>
|
||||
{#if suggestion.current_value !== null && suggestion.current_value !== undefined}
|
||||
<div class="text-xs text-stone-500 dark:text-stone-400">
|
||||
Aktuell: {formatValue(suggestion.current_value, suggestion.field)}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-sm text-stone-900 dark:text-stone-100">
|
||||
{#if suggestion.field === 'opening_hours' && isLLMOeffnungszeiten(suggestion.suggested_value)}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-stone-500 dark:text-stone-400">
|
||||
<th class="pr-4 pb-1 font-medium">Datum</th>
|
||||
<th class="pr-4 pb-1 font-medium">Von</th>
|
||||
<th class="pb-1 font-medium">Bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each suggestion.suggested_value as entry}
|
||||
<tr>
|
||||
<td class="py-0.5 pr-4">{entry.datum_von}</td>
|
||||
<td class="py-0.5 pr-4">{entry.von}</td>
|
||||
<td class="py-0.5">{entry.bis}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else if suggestion.field === 'opening_hours' && isOpeningHours(suggestion.suggested_value)}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-stone-500 dark:text-stone-400">
|
||||
<th class="pr-4 pb-1 font-medium">Tag</th>
|
||||
<th class="pr-4 pb-1 font-medium">Von</th>
|
||||
<th class="pb-1 font-medium">Bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each suggestion.suggested_value as entry}
|
||||
<tr>
|
||||
<td class="py-0.5 pr-4">{entry.day}</td>
|
||||
<td class="py-0.5 pr-4">{entry.open}</td>
|
||||
<td class="py-0.5">{entry.close}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else if suggestion.field === 'admission_info' && isLLMEintrittspreise(suggestion.suggested_value)}
|
||||
<ul class="space-y-0.5 text-sm">
|
||||
{#each suggestion.suggested_value as ticket}
|
||||
<li>{ticket.name}: {ticket.betrag} {ticket.waehrung}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if suggestion.field === 'admission_info' && isAdmissionInfo(suggestion.suggested_value)}
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
<dt class="text-stone-500 dark:text-stone-400">Erwachsene</dt>
|
||||
<dd>{formatCents(suggestion.suggested_value.adult_cents)}</dd>
|
||||
<dt class="text-stone-500 dark:text-stone-400">Kinder</dt>
|
||||
<dd>{formatCents(suggestion.suggested_value.child_cents)}</dd>
|
||||
<dt class="text-stone-500 dark:text-stone-400">Ermäßigt</dt>
|
||||
<dd>{formatCents(suggestion.suggested_value.reduced_cents)}</dd>
|
||||
{#if suggestion.suggested_value.free_under_age > 0}
|
||||
<dt class="text-stone-500 dark:text-stone-400">Frei unter</dt>
|
||||
<dd>{suggestion.suggested_value.free_under_age} Jahre</dd>
|
||||
{/if}
|
||||
{#if suggestion.suggested_value.notes}
|
||||
<dt class="col-span-2 mt-1 text-stone-500 dark:text-stone-400">
|
||||
{suggestion.suggested_value.notes}
|
||||
</dt>
|
||||
{/if}
|
||||
</dl>
|
||||
{:else}
|
||||
{formatValue(suggestion.suggested_value, suggestion.field)}
|
||||
{/if}
|
||||
</div>
|
||||
{#if suggestion.reason}
|
||||
<div class="text-xs text-stone-500 italic dark:text-stone-400">
|
||||
{suggestion.reason}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button type="button" onclick={handleApply}>Übernehmen</Button>
|
||||
<Button type="button" variant="secondary" onclick={onClose}>Abbrechen</Button>
|
||||
{#if result.plan.cross_warnings.length > 0}
|
||||
<div
|
||||
class="mb-4 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 dark:border-amber-700 dark:bg-amber-950"
|
||||
>
|
||||
<p class="mb-1 text-xs font-semibold text-amber-800 dark:text-amber-200">Hinweise</p>
|
||||
<ul class="space-y-0.5">
|
||||
{#each result.plan.cross_warnings as warning}
|
||||
<li class="text-sm text-amber-800 dark:text-amber-200">{warning}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if result.sources && result.sources.length > 0}
|
||||
{#if totalSelectable === 0 && result.plan.rejected.length === 0}
|
||||
<p class="text-sm text-stone-600 dark:text-stone-400">Keine Vorschläge gefunden.</p>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Section: Auto-übernehmen -->
|
||||
{#if result.plan.auto_apply.length > 0}
|
||||
<section>
|
||||
<h4
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold text-stone-700 dark:text-stone-300"
|
||||
>
|
||||
Auto-übernehmen
|
||||
<span
|
||||
class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
>
|
||||
Automatisch
|
||||
</span>
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
{#each result.plan.auto_apply as item, i}
|
||||
<label
|
||||
class="flex cursor-pointer gap-3 rounded-lg border border-stone-200 p-3 transition-colors hover:bg-stone-100 dark:border-stone-700 dark:hover:bg-stone-800"
|
||||
>
|
||||
<input type="checkbox" bind:checked={autoSelected[i]} class="mt-1" />
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-stone-800 dark:text-stone-100">
|
||||
{fieldLabels[item.field] ?? item.field}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {confidenceColors[
|
||||
item.confidence
|
||||
]}"
|
||||
>
|
||||
{badgeLabel(item)}
|
||||
</span>
|
||||
</div>
|
||||
{#if item.current !== null && item.current !== undefined}
|
||||
<div class="text-xs text-stone-500 dark:text-stone-400">
|
||||
Aktuell: {formatValue(item.current, item.field)}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-sm text-stone-900 dark:text-stone-100">
|
||||
{#if item.field === 'opening_hours' && isLLMOeffnungszeiten(item.suggested)}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-stone-500 dark:text-stone-400">
|
||||
<th class="pr-4 pb-1 font-medium">Datum</th>
|
||||
<th class="pr-4 pb-1 font-medium">Von</th>
|
||||
<th class="pb-1 font-medium">Bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each item.suggested as entry}
|
||||
<tr>
|
||||
<td class="py-0.5 pr-4">{entry.datum_von}</td>
|
||||
<td class="py-0.5 pr-4">{entry.von}</td>
|
||||
<td class="py-0.5">{entry.bis}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else if item.field === 'opening_hours' && isOpeningHours(item.suggested)}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-stone-500 dark:text-stone-400">
|
||||
<th class="pr-4 pb-1 font-medium">Tag</th>
|
||||
<th class="pr-4 pb-1 font-medium">Von</th>
|
||||
<th class="pb-1 font-medium">Bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each item.suggested as entry}
|
||||
<tr>
|
||||
<td class="py-0.5 pr-4">{entry.day}</td>
|
||||
<td class="py-0.5 pr-4">{entry.open}</td>
|
||||
<td class="py-0.5">{entry.close}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else if item.field === 'admission_info' && isLLMEintrittspreise(item.suggested)}
|
||||
<ul class="space-y-0.5 text-sm">
|
||||
{#each item.suggested as ticket}
|
||||
<li>{ticket.name}: {ticket.betrag} {ticket.waehrung}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if item.field === 'admission_info' && isAdmissionInfo(item.suggested)}
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
<dt class="text-stone-500 dark:text-stone-400">Erwachsene</dt>
|
||||
<dd>{formatCents(item.suggested.adult_cents)}</dd>
|
||||
<dt class="text-stone-500 dark:text-stone-400">Kinder</dt>
|
||||
<dd>{formatCents(item.suggested.child_cents)}</dd>
|
||||
<dt class="text-stone-500 dark:text-stone-400">Ermäßigt</dt>
|
||||
<dd>{formatCents(item.suggested.reduced_cents)}</dd>
|
||||
{#if item.suggested.free_under_age > 0}
|
||||
<dt class="text-stone-500 dark:text-stone-400">Frei unter</dt>
|
||||
<dd>{item.suggested.free_under_age} Jahre</dd>
|
||||
{/if}
|
||||
{#if item.suggested.notes}
|
||||
<dt class="col-span-2 mt-1 text-stone-500 dark:text-stone-400">
|
||||
{item.suggested.notes}
|
||||
</dt>
|
||||
{/if}
|
||||
</dl>
|
||||
{:else}
|
||||
{formatValue(item.suggested, item.field)}
|
||||
{/if}
|
||||
</div>
|
||||
{#if item.reason}
|
||||
<div class="text-xs text-stone-500 italic dark:text-stone-400">
|
||||
{item.reason}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Section: Prüfen -->
|
||||
{#if result.plan.review_required.length > 0}
|
||||
<section>
|
||||
<h4
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold text-stone-700 dark:text-stone-300"
|
||||
>
|
||||
Prüfen
|
||||
<span
|
||||
class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900 dark:text-amber-200"
|
||||
>
|
||||
Prüfen
|
||||
</span>
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
{#each result.plan.review_required as item, i}
|
||||
<label
|
||||
class="flex cursor-pointer gap-3 rounded-lg border border-stone-200 p-3 transition-colors hover:bg-stone-100 dark:border-stone-700 dark:hover:bg-stone-800"
|
||||
>
|
||||
<input type="checkbox" bind:checked={reviewSelected[i]} class="mt-1" />
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-stone-800 dark:text-stone-100">
|
||||
{fieldLabels[item.field] ?? item.field}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {confidenceColors[
|
||||
item.confidence
|
||||
]}"
|
||||
>
|
||||
{badgeLabel(item)}
|
||||
</span>
|
||||
</div>
|
||||
{#if item.current !== null && item.current !== undefined}
|
||||
<div class="text-xs text-stone-500 dark:text-stone-400">
|
||||
Aktuell: {formatValue(item.current, item.field)}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-sm text-stone-900 dark:text-stone-100">
|
||||
{#if item.field === 'opening_hours' && isLLMOeffnungszeiten(item.suggested)}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-stone-500 dark:text-stone-400">
|
||||
<th class="pr-4 pb-1 font-medium">Datum</th>
|
||||
<th class="pr-4 pb-1 font-medium">Von</th>
|
||||
<th class="pb-1 font-medium">Bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each item.suggested as entry}
|
||||
<tr>
|
||||
<td class="py-0.5 pr-4">{entry.datum_von}</td>
|
||||
<td class="py-0.5 pr-4">{entry.von}</td>
|
||||
<td class="py-0.5">{entry.bis}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else if item.field === 'opening_hours' && isOpeningHours(item.suggested)}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-stone-500 dark:text-stone-400">
|
||||
<th class="pr-4 pb-1 font-medium">Tag</th>
|
||||
<th class="pr-4 pb-1 font-medium">Von</th>
|
||||
<th class="pb-1 font-medium">Bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each item.suggested as entry}
|
||||
<tr>
|
||||
<td class="py-0.5 pr-4">{entry.day}</td>
|
||||
<td class="py-0.5 pr-4">{entry.open}</td>
|
||||
<td class="py-0.5">{entry.close}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else if item.field === 'admission_info' && isLLMEintrittspreise(item.suggested)}
|
||||
<ul class="space-y-0.5 text-sm">
|
||||
{#each item.suggested as ticket}
|
||||
<li>{ticket.name}: {ticket.betrag} {ticket.waehrung}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if item.field === 'admission_info' && isAdmissionInfo(item.suggested)}
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
<dt class="text-stone-500 dark:text-stone-400">Erwachsene</dt>
|
||||
<dd>{formatCents(item.suggested.adult_cents)}</dd>
|
||||
<dt class="text-stone-500 dark:text-stone-400">Kinder</dt>
|
||||
<dd>{formatCents(item.suggested.child_cents)}</dd>
|
||||
<dt class="text-stone-500 dark:text-stone-400">Ermäßigt</dt>
|
||||
<dd>{formatCents(item.suggested.reduced_cents)}</dd>
|
||||
{#if item.suggested.free_under_age > 0}
|
||||
<dt class="text-stone-500 dark:text-stone-400">Frei unter</dt>
|
||||
<dd>{item.suggested.free_under_age} Jahre</dd>
|
||||
{/if}
|
||||
{#if item.suggested.notes}
|
||||
<dt class="col-span-2 mt-1 text-stone-500 dark:text-stone-400">
|
||||
{item.suggested.notes}
|
||||
</dt>
|
||||
{/if}
|
||||
</dl>
|
||||
{:else}
|
||||
{formatValue(item.suggested, item.field)}
|
||||
{/if}
|
||||
</div>
|
||||
{#if item.decision_reason}
|
||||
<div
|
||||
class="mt-1 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-200"
|
||||
>
|
||||
{item.decision_reason}
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.reason}
|
||||
<div class="text-xs text-stone-500 italic dark:text-stone-400">
|
||||
{item.reason}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Section: Abgelehnt -->
|
||||
{#if result.plan.rejected.length > 0}
|
||||
<section>
|
||||
<h4
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold text-stone-700 dark:text-stone-300"
|
||||
>
|
||||
Abgelehnt
|
||||
<span
|
||||
class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
>
|
||||
Abgelehnt
|
||||
</span>
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
{#each result.plan.rejected as item}
|
||||
<div
|
||||
class="flex gap-3 rounded-lg border border-stone-200 p-3 opacity-60 dark:border-stone-700"
|
||||
>
|
||||
<input type="checkbox" disabled class="mt-1" />
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-stone-800 dark:text-stone-100">
|
||||
{fieldLabels[item.field] ?? item.field}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
>
|
||||
{item.confidence === 'high' ? 'Hoch' : 'Mittel'}
|
||||
</span>
|
||||
</div>
|
||||
{#if item.current !== null && item.current !== undefined}
|
||||
<div class="text-xs text-stone-500 dark:text-stone-400">
|
||||
Aktuell: {formatValue(item.current, item.field)}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-sm text-stone-900 dark:text-stone-100">
|
||||
{formatValue(item.suggested, item.field)}
|
||||
</div>
|
||||
{#if item.decision_reason}
|
||||
<div
|
||||
class="mt-1 rounded border border-red-200 bg-red-50 px-2 py-1 text-xs text-red-800 dark:border-red-700 dark:bg-red-950 dark:text-red-200"
|
||||
>
|
||||
{item.decision_reason}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if totalSelectable > 0}
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button type="button" onclick={handleApply}>Übernehmen</Button>
|
||||
<Button type="button" variant="secondary" onclick={onClose}>Abbrechen</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if result.research_result.sources && result.research_result.sources.length > 0}
|
||||
<div class="mt-4 border-t border-stone-200 pt-3 dark:border-stone-700">
|
||||
<p class="mb-1 text-xs font-medium text-stone-500 dark:text-stone-400">Quellen:</p>
|
||||
<ul class="space-y-0.5">
|
||||
{#each result.sources as source}
|
||||
{#each result.research_result.sources as source}
|
||||
<li>
|
||||
<a
|
||||
href={source}
|
||||
|
||||
@@ -3,107 +3,53 @@
|
||||
import MarketForm from '$lib/components/admin/MarketForm.svelte';
|
||||
import ResearchPanel from '$lib/components/admin/ResearchPanel.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import type {
|
||||
ResearchResult,
|
||||
FieldSuggestion,
|
||||
OpeningHoursEntry,
|
||||
AdmissionInfo
|
||||
} from '$lib/api/types.js';
|
||||
import type { PlanResponse } from '$lib/api/types.js';
|
||||
|
||||
let { data, form } = $props();
|
||||
let loading = $state(false);
|
||||
let researching = $state(false);
|
||||
let researchResult: ResearchResult | null = $state(null);
|
||||
let dismissed = $state(false);
|
||||
let planResult: PlanResponse | null = $state(null);
|
||||
let planError: string | null = $state(null);
|
||||
let marketForm: MarketForm;
|
||||
|
||||
$effect(() => {
|
||||
if (form?.research && !dismissed) {
|
||||
researchResult = form.research as ResearchResult;
|
||||
async function runResearchPlan() {
|
||||
researching = true;
|
||||
planError = null;
|
||||
try {
|
||||
const res = await fetch(`/admin/maerkte/${data.market.id}/bearbeiten/research-plan`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok) {
|
||||
planError = body.error ?? 'Recherche-Plan fehlgeschlagen.';
|
||||
return;
|
||||
}
|
||||
planResult = body as PlanResponse;
|
||||
} catch (err) {
|
||||
planError = err instanceof Error ? err.message : 'Recherche-Plan fehlgeschlagen.';
|
||||
} finally {
|
||||
researching = false;
|
||||
}
|
||||
});
|
||||
|
||||
const validDays = [
|
||||
'Sonntag',
|
||||
'Montag',
|
||||
'Dienstag',
|
||||
'Mittwoch',
|
||||
'Donnerstag',
|
||||
'Freitag',
|
||||
'Samstag'
|
||||
];
|
||||
|
||||
// LLM opening hours entry shape (researcher_schema_simple.json)
|
||||
type LLMOeffnungszeit = { datum_von: string; datum_bis: string; von: string; bis: string };
|
||||
// LLM admission entry shape
|
||||
type LLMEintrittspreis = { name: string; betrag: number; waehrung: string };
|
||||
|
||||
function normalizeDayName(day: string): string {
|
||||
// ISO date YYYY-MM-DD
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(day)) {
|
||||
const [y, m, d] = day.split('-').map(Number);
|
||||
return validDays[new Date(y, m - 1, d).getDay()];
|
||||
}
|
||||
// German date DD.MM.YYYY
|
||||
const dateMatch = day.match(/(\d{2})\.(\d{2})\.(\d{4})/);
|
||||
if (dateMatch) {
|
||||
const d = new Date(+dateMatch[3], +dateMatch[2] - 1, +dateMatch[1]);
|
||||
return validDays[d.getDay()];
|
||||
}
|
||||
const match = validDays.find((d) => day.startsWith(d));
|
||||
return match ?? day;
|
||||
}
|
||||
|
||||
function applyResearch(suggestions: FieldSuggestion[]) {
|
||||
for (const s of suggestions) {
|
||||
if (s.field === 'opening_hours' && Array.isArray(s.suggested_value)) {
|
||||
const entries = s.suggested_value as Array<LLMOeffnungszeit | OpeningHoursEntry>;
|
||||
const normalized: OpeningHoursEntry[] = entries.map((entry) => {
|
||||
if ('datum_von' in entry) {
|
||||
return { day: normalizeDayName(entry.datum_von), open: entry.von, close: entry.bis };
|
||||
}
|
||||
return { ...entry, day: normalizeDayName(entry.day) };
|
||||
});
|
||||
marketForm.setHours(normalized);
|
||||
continue;
|
||||
}
|
||||
if (s.field === 'admission_info') {
|
||||
if (Array.isArray(s.suggested_value)) {
|
||||
// LLM format: [{name, betrag, waehrung}] — put in notes, leave cents at 0
|
||||
const tickets = s.suggested_value as LLMEintrittspreis[];
|
||||
const notes = tickets.map((t) => `${t.name}: ${t.betrag} ${t.waehrung}`).join('\n');
|
||||
marketForm.setAdmission({
|
||||
adult_cents: 0,
|
||||
child_cents: 0,
|
||||
reduced_cents: 0,
|
||||
free_under_age: 0,
|
||||
notes
|
||||
});
|
||||
} else if (typeof s.suggested_value === 'object' && s.suggested_value !== null) {
|
||||
marketForm.setAdmission(s.suggested_value as AdmissionInfo);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (typeof s.suggested_value === 'string') {
|
||||
marketForm.setField(s.field, s.suggested_value);
|
||||
}
|
||||
}
|
||||
const notesEl = document.querySelector<HTMLTextAreaElement>('[name="admin_notes"]');
|
||||
if (notesEl) {
|
||||
const ts = new Date().toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
async function applyPlan(fields: string[]) {
|
||||
if (!planResult) return;
|
||||
try {
|
||||
const res = await fetch(`/admin/maerkte/${data.market.id}/bearbeiten/research-apply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ research_result: planResult.research_result, fields })
|
||||
});
|
||||
const note = `KI-Recherche: ${ts}`;
|
||||
notesEl.value = notesEl.value ? `${notesEl.value}\n${note}` : note;
|
||||
notesEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const body = await res.json();
|
||||
if (!res.ok) {
|
||||
planError = body.error ?? 'Anwenden fehlgeschlagen.';
|
||||
return;
|
||||
}
|
||||
planResult = null;
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
planError = err instanceof Error ? err.message : 'Anwenden fehlgeschlagen.';
|
||||
}
|
||||
const saveForm = document.querySelector<HTMLFormElement>('form[action="?/save"]');
|
||||
saveForm?.requestSubmit();
|
||||
researchResult = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -123,33 +69,23 @@
|
||||
<h1 class="mt-1 text-2xl font-bold">Markt bearbeiten</h1>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/research"
|
||||
use:enhance={() => {
|
||||
researching = true;
|
||||
return async ({ update }) => {
|
||||
researching = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" variant="secondary" loading={researching}>Mit KI recherchieren</Button
|
||||
>
|
||||
</form>
|
||||
{#if form?.error && !form?.research}
|
||||
<Button type="button" variant="secondary" loading={researching} onclick={runResearchPlan}>
|
||||
Mit KI recherchieren
|
||||
</Button>
|
||||
{#if planError}
|
||||
<p class="text-xs text-red-500 dark:text-red-400">{planError}</p>
|
||||
{:else if form?.error && !form?.research}
|
||||
<p class="text-xs text-red-500 dark:text-red-400">{form.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if researchResult}
|
||||
{#if planResult}
|
||||
<ResearchPanel
|
||||
result={researchResult}
|
||||
onApply={applyResearch}
|
||||
result={planResult}
|
||||
onApply={applyPlan}
|
||||
onClose={() => {
|
||||
researchResult = null;
|
||||
dismissed = true;
|
||||
planResult = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { serverFetch } from '$lib/api/client.server.js';
|
||||
import type { AdminMarketDetail, ResearchResult } from '$lib/api/types.js';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, params, request }) => {
|
||||
try {
|
||||
const body = (await request.json()) as { research_result: ResearchResult; fields: string[] };
|
||||
const res = await serverFetch<AdminMarketDetail>(
|
||||
`/admin/markets/${params.id}/research/apply`,
|
||||
cookies,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
);
|
||||
return json(res.data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Anwenden fehlgeschlagen.';
|
||||
return json({ error: message }, { status: 502 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { serverFetch } from '$lib/api/client.server.js';
|
||||
import type { PlanResponse } from '$lib/api/types.js';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, params }) => {
|
||||
try {
|
||||
const res = await serverFetch<PlanResponse>(
|
||||
`/admin/markets/${params.id}/research/plan`,
|
||||
cookies,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
return json(res.data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Recherche-Plan fehlgeschlagen.';
|
||||
return json({ error: message }, { status: 502 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user