feat(admin/ui): three-section merge plan UI + plan/apply proxy endpoints (D6)

This commit is contained in:
2026-04-25 18:45:40 +02:00
parent 65c8c4bf96
commit 9b308639fd
5 changed files with 454 additions and 226 deletions

View File

@@ -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;

View File

@@ -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}

View File

@@ -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}

View File

@@ -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 });
}
};

View File

@@ -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 });
}
};