feat(admin/dedup): merge UI + enrich enum fix + robust JSON parse
H1: Drop empty string from enricher_schema.json category enum — Gemini rejects enum[7]: cannot be empty (Error 400). Remove category from required so the model can omit it when no category fits. H2: Research-plan/apply client reads response as text before JSON.parse; empty or HTML error bodies now surface the actual HTTP status instead of crashing with "unexpected end of data". I: Dedup UI for approved markets: - DuplicatesPanel: LLM verdict pills (same/not-same, confidence), llm_reason, per-candidate Merge-planen button - MergeProposalPanel: summary, confidence, flags, per-field decisions with editable source radio (a/b/combined), current value context, confirm() before destructive apply - Two SvelteKit proxy routes: merge-plan/ and merge-into/[targetId]/ - [id]/+page.svelte: wired with full state; navigates to survivor after successful merge - [id]/+page.server.ts: load duplicates for all non-merged editions (was gated to status=rumored only) - types.ts: DuplicateMarket gains llm_same/llm_confidence/llm_reason; add MarketMergeProposal + MergeFieldDecision; add merged to EditionStatus
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["category", "opening_hours", "description"],
|
||||
"required": ["opening_hours", "description"],
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Market category. Use empty string if unclear.",
|
||||
"description": "Market category. Omit this field entirely if sources are unclear.",
|
||||
"enum": ["mittelaltermarkt", "weihnachtsmarkt", "ritterfest",
|
||||
"handwerkermarkt", "schlossfest", "ritterturnier",
|
||||
"kirchweih", ""]
|
||||
"kirchweih"]
|
||||
},
|
||||
"opening_hours": {
|
||||
"type": "string",
|
||||
|
||||
@@ -171,7 +171,7 @@ Category taxonomy — pick exactly one:
|
||||
schlossfest Castle or palace festival — broader programme, not just market
|
||||
ritterturnier Jousting tournament; the tournament IS the main event
|
||||
kirchweih Traditional Bavarian/Austrian parish fair (Kirmes, Kirtag)
|
||||
(empty string) Sources unclear or event does not fit any category above
|
||||
(omit field) Sources unclear or event does not fit any category above
|
||||
|
||||
Examples:
|
||||
"Dresdner Striezelmarkt", christmas-market sources → "weihnachtsmarkt"
|
||||
@@ -181,7 +181,8 @@ Examples:
|
||||
|
||||
Rules:
|
||||
- Return ONLY the JSON object. No prose, no code fences.
|
||||
- If the sources do not support a field, return an empty string for it.
|
||||
- If the sources do not support a string field, return an empty string for it.
|
||||
For "category", omit the field entirely when no category fits.
|
||||
Do NOT invent details the sources don't mention.
|
||||
- The description must be factual; avoid marketing language.
|
||||
- All string values use straight ASCII quotes; escape internal quotes.
|
||||
|
||||
@@ -115,7 +115,8 @@ export type EditionStatus =
|
||||
| 'active'
|
||||
| 'completed'
|
||||
| 'cancelled'
|
||||
| 'archived';
|
||||
| 'archived'
|
||||
| 'merged';
|
||||
|
||||
// Keep backward compat alias
|
||||
export type MarketStatus = EditionStatus;
|
||||
@@ -284,6 +285,26 @@ export interface DuplicateMarket {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
similarity: number;
|
||||
// Populated for top-5 candidates only (E1)
|
||||
llm_same?: boolean;
|
||||
llm_confidence?: number;
|
||||
llm_reason?: string;
|
||||
}
|
||||
|
||||
// AI merge advisor types (E2/E5)
|
||||
export interface MergeFieldDecision {
|
||||
source: 'a' | 'b' | 'combined';
|
||||
value: unknown;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface MarketMergeProposal {
|
||||
target_id: string;
|
||||
target_reason: string;
|
||||
field_merges: Record<string, MergeFieldDecision>;
|
||||
flags: string[];
|
||||
confidence: number;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
// Series types
|
||||
|
||||
106
web/src/lib/components/admin/DuplicatesPanel.svelte
Normal file
106
web/src/lib/components/admin/DuplicatesPanel.svelte
Normal file
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import type { DuplicateMarket } from '$lib/api/types.js';
|
||||
|
||||
interface Props {
|
||||
duplicates: DuplicateMarket[];
|
||||
marketId: string;
|
||||
onPlan: (candidate: DuplicateMarket) => void;
|
||||
planningId?: string | null; // ID of candidate currently being loaded
|
||||
}
|
||||
|
||||
let { duplicates, onPlan, planningId = null }: Props = $props();
|
||||
|
||||
function formatDate(s: string): string {
|
||||
if (!s) return '—';
|
||||
try {
|
||||
return new Date(s).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
function llmBadgeClass(d: DuplicateMarket): string {
|
||||
if (d.llm_same === undefined || d.llm_same === null) return '';
|
||||
const conf = d.llm_confidence ?? 0;
|
||||
if (d.llm_same && conf >= 0.8)
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||
if (d.llm_same) return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200';
|
||||
if (conf >= 0.7) return 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-400';
|
||||
return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200';
|
||||
}
|
||||
|
||||
function llmBadgeLabel(d: DuplicateMarket): string {
|
||||
if (d.llm_same === undefined || d.llm_same === null) return '';
|
||||
const pct = Math.round((d.llm_confidence ?? 0) * 100);
|
||||
if (d.llm_same && (d.llm_confidence ?? 0) >= 0.8) return `Wahrscheinlich Duplikat (${pct}%)`;
|
||||
if (d.llm_same) return `Möglich (${pct}%)`;
|
||||
if ((d.llm_confidence ?? 0) >= 0.7) return `Wahrscheinlich kein Duplikat (${pct}%)`;
|
||||
return `Unklar (${pct}%)`;
|
||||
}
|
||||
|
||||
// Disable the merge button only when the LLM is confident it's NOT a duplicate.
|
||||
function isMergeDiscouraged(d: DuplicateMarket): boolean {
|
||||
return d.llm_same === false && (d.llm_confidence ?? 0) >= 0.7;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-950"
|
||||
>
|
||||
<h3 class="mb-3 font-semibold text-amber-800 dark:text-amber-200">Mögliche Duplikate gefunden</h3>
|
||||
<ul class="space-y-3">
|
||||
{#each duplicates as dup}
|
||||
<li class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
href="/admin/maerkte/{dup.id}"
|
||||
class="text-sm font-medium text-amber-900 hover:underline dark:text-amber-100"
|
||||
>
|
||||
{dup.name}
|
||||
</a>
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400">
|
||||
({Math.round(dup.similarity * 100)}% Ähnlichkeit)
|
||||
</span>
|
||||
{#if dup.llm_same !== undefined && dup.llm_same !== null}
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {llmBadgeClass(dup)}"
|
||||
title={dup.llm_reason ?? ''}
|
||||
>
|
||||
{llmBadgeLabel(dup)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
|
||||
{dup.city} · {formatDate(dup.start_date)} – {formatDate(dup.end_date)}
|
||||
</div>
|
||||
{#if dup.llm_reason}
|
||||
<div class="mt-0.5 text-xs text-amber-600 italic dark:text-amber-400">
|
||||
{dup.llm_reason}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col items-end gap-1">
|
||||
{#if isMergeDiscouraged(dup)}
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-amber-600 hover:underline dark:text-amber-400"
|
||||
onclick={() => onPlan(dup)}
|
||||
>
|
||||
{planningId === dup.id ? 'Lädt…' : 'Trotzdem mergen'}
|
||||
</button>
|
||||
{:else}
|
||||
<Button size="sm" loading={planningId === dup.id} onclick={() => onPlan(dup)}>
|
||||
Merge planen
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
163
web/src/lib/components/admin/MergeProposalPanel.svelte
Normal file
163
web/src/lib/components/admin/MergeProposalPanel.svelte
Normal file
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import type { AdminMarketDetail, DuplicateMarket, MarketMergeProposal } from '$lib/api/types.js';
|
||||
import { fieldLabels, formatValue } from './fieldRenderers.js';
|
||||
|
||||
interface Props {
|
||||
proposal: MarketMergeProposal;
|
||||
candidate: DuplicateMarket;
|
||||
current: AdminMarketDetail;
|
||||
applying: boolean;
|
||||
onApply: (edited: MarketMergeProposal) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { proposal, candidate, current, applying, onApply, onClose }: Props = $props();
|
||||
|
||||
// Local copy the admin can edit before applying.
|
||||
let edited = $state(untrack(() => structuredClone(proposal)));
|
||||
|
||||
const fieldOrder = Object.keys(edited.field_merges);
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
if (source === 'a') return 'Dieser Markt (A)';
|
||||
if (source === 'b') return 'Kandidat (B)';
|
||||
return 'Kombiniert';
|
||||
}
|
||||
|
||||
function confidenceColor(conf: number): string {
|
||||
if (conf >= 0.8) return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
if (conf >= 0.5) return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200';
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||
}
|
||||
|
||||
// Read current market value for a field.
|
||||
function currentValue(field: string): unknown {
|
||||
return (current as unknown as Record<string, unknown>)[field];
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
if (
|
||||
!confirm(
|
||||
'Merge ist nicht reversibel. Die Source-Edition wird auf "merged" gesetzt. Fortfahren?'
|
||||
)
|
||||
)
|
||||
return;
|
||||
onApply(edited);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-stone-200 bg-stone-50 p-6 dark:border-stone-700 dark:bg-stone-900"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-stone-800 dark:text-stone-100">Merge-Vorschlag</h3>
|
||||
<p class="mt-1 text-sm text-stone-600 dark:text-stone-400">{edited.summary}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="shrink-0 text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Confidence + target info -->
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {confidenceColor(edited.confidence)}">
|
||||
Konfidenz {Math.round(edited.confidence * 100)}%
|
||||
</span>
|
||||
<span class="text-sm text-stone-600 dark:text-stone-400">
|
||||
<span class="font-medium">Ziel:</span>
|
||||
{edited.target_id === current.id
|
||||
? `Dieser Markt (${current.name})`
|
||||
: `Kandidat (${candidate.name})`}
|
||||
— {edited.target_reason}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Flags -->
|
||||
{#if edited.flags.length > 0}
|
||||
<div
|
||||
class="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 dark:border-red-700 dark:bg-red-950"
|
||||
>
|
||||
<p class="mb-1 text-xs font-semibold text-red-800 dark:text-red-200">
|
||||
Hinweise für manuelle Prüfung
|
||||
</p>
|
||||
<ul class="space-y-0.5">
|
||||
{#each edited.flags as flag}
|
||||
<li class="text-sm text-red-800 dark:text-red-200">{flag}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Field decisions -->
|
||||
{#if fieldOrder.length > 0}
|
||||
<div class="mb-4 space-y-2">
|
||||
<h4 class="text-sm font-semibold text-stone-700 dark:text-stone-300">Felder</h4>
|
||||
{#each fieldOrder as field}
|
||||
{@const decision = edited.field_merges[field]}
|
||||
{@const cur = currentValue(field)}
|
||||
<div class="rounded-lg border border-stone-200 p-3 dark:border-stone-700">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-stone-800 dark:text-stone-100">
|
||||
{fieldLabels[field] ?? field}
|
||||
</span>
|
||||
<span class="text-xs text-stone-500 dark:text-stone-400">
|
||||
{sourceLabel(decision.source)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Current value for context -->
|
||||
{#if cur !== null && cur !== undefined && cur !== ''}
|
||||
<div class="mb-1 text-xs text-stone-500 dark:text-stone-400">
|
||||
Aktuell: {formatValue(cur)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Proposed value -->
|
||||
<div class="mb-2 text-sm text-stone-900 dark:text-stone-100">
|
||||
{formatValue(decision.value)}
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
{#if decision.reason}
|
||||
<div class="mb-2 text-xs text-stone-500 italic dark:text-stone-400">
|
||||
{decision.reason}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Editable source radio -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each ['a', 'b', 'combined'] as src}
|
||||
<label class="flex cursor-pointer items-center gap-1 text-xs">
|
||||
<input
|
||||
type="radio"
|
||||
name="source_{field}"
|
||||
value={src}
|
||||
bind:group={edited.field_merges[field].source}
|
||||
/>
|
||||
{sourceLabel(src)}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-4 text-sm text-stone-600 dark:text-stone-400">
|
||||
Keine Feld-Entscheidungen im Vorschlag.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex gap-2">
|
||||
<Button variant="danger" loading={applying} onclick={handleApply}>Merge anwenden</Button>
|
||||
<Button variant="secondary" onclick={onClose}>Abbrechen</Button>
|
||||
</div>
|
||||
</div>
|
||||
27
web/src/lib/components/admin/fieldRenderers.ts
Normal file
27
web/src/lib/components/admin/fieldRenderers.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const fieldLabels: Record<string, string> = {
|
||||
name: 'Name',
|
||||
description: 'Beschreibung',
|
||||
street: 'Straße',
|
||||
city: 'Stadt',
|
||||
zip: 'PLZ',
|
||||
country: 'Land',
|
||||
state: 'Bundesland',
|
||||
website: 'Website',
|
||||
organizer_name: 'Veranstalter',
|
||||
start_date: 'Startdatum',
|
||||
end_date: 'Enddatum',
|
||||
opening_hours: 'Öffnungszeiten',
|
||||
admission_info: 'Eintrittspreise',
|
||||
image_url: 'Bild-URL',
|
||||
logo_url: 'Logo-URL'
|
||||
};
|
||||
|
||||
export function formatValue(val: unknown): string {
|
||||
if (val === null || val === undefined) return '—';
|
||||
if (typeof val !== 'object') return String(val);
|
||||
return JSON.stringify(val, null, 2);
|
||||
}
|
||||
|
||||
export function formatCents(cents: number): string {
|
||||
return (cents / 100).toFixed(2);
|
||||
}
|
||||
@@ -24,7 +24,8 @@
|
||||
active: 'Aktiv',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Abgesagt',
|
||||
archived: 'Archiviert'
|
||||
archived: 'Archiviert',
|
||||
merged: 'Zusammengeführt'
|
||||
};
|
||||
|
||||
const statusColors: Record<EditionStatus, string> = {
|
||||
@@ -33,7 +34,8 @@
|
||||
active: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
completed: 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-400',
|
||||
cancelled: 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-200',
|
||||
archived: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-500'
|
||||
archived: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-500',
|
||||
merged: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-400'
|
||||
};
|
||||
|
||||
const tabs: { label: string; value: string }[] = [
|
||||
|
||||
@@ -13,7 +13,8 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
const market = res.data;
|
||||
|
||||
let duplicates: DuplicateMarket[] = [];
|
||||
if (market.status === 'rumored') {
|
||||
// Load duplicates for all non-merged editions — admins may want to merge confirmed/active pairs too.
|
||||
if (market.status !== 'merged') {
|
||||
try {
|
||||
const dupRes = await serverFetch<DuplicateMarket[]>(
|
||||
`/admin/markets/${params.id}/duplicates`,
|
||||
|
||||
@@ -3,20 +3,87 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import type { EditionStatus } from '$lib/api/types.js';
|
||||
import DuplicatesPanel from '$lib/components/admin/DuplicatesPanel.svelte';
|
||||
import MergeProposalPanel from '$lib/components/admin/MergeProposalPanel.svelte';
|
||||
import type { EditionStatus, DuplicateMarket, MarketMergeProposal } from '$lib/api/types.js';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let showNewEdition = $state(false);
|
||||
|
||||
// Merge state
|
||||
let proposal: MarketMergeProposal | null = $state(null);
|
||||
let proposalCandidate: DuplicateMarket | null = $state(null);
|
||||
let planningId: string | null = $state(null);
|
||||
let mergeError: string | null = $state(null);
|
||||
let applying = $state(false);
|
||||
|
||||
async function handlePlan(candidate: DuplicateMarket) {
|
||||
mergeError = null;
|
||||
planningId = candidate.id;
|
||||
try {
|
||||
const res = await fetch(`/admin/maerkte/${data.market.id}/merge-plan`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target_id: candidate.id })
|
||||
});
|
||||
const text = await res.text();
|
||||
let body: { error?: string; [k: string]: unknown } = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
// non-JSON body
|
||||
}
|
||||
if (!res.ok) {
|
||||
mergeError = body.error ?? `Merge-Plan fehlgeschlagen (HTTP ${res.status}).`;
|
||||
return;
|
||||
}
|
||||
proposal = body as unknown as MarketMergeProposal;
|
||||
proposalCandidate = candidate;
|
||||
} catch (err) {
|
||||
mergeError = err instanceof Error ? err.message : 'Merge-Plan fehlgeschlagen.';
|
||||
} finally {
|
||||
planningId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyMerge(edited: MarketMergeProposal) {
|
||||
applying = true;
|
||||
mergeError = null;
|
||||
try {
|
||||
const res = await fetch(`/admin/maerkte/${data.market.id}/merge-into/${edited.target_id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edited)
|
||||
});
|
||||
const text = await res.text();
|
||||
let body: { error?: string; [k: string]: unknown } = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
// non-JSON body
|
||||
}
|
||||
if (!res.ok) {
|
||||
mergeError = body.error ?? `Merge fehlgeschlagen (HTTP ${res.status}).`;
|
||||
return;
|
||||
}
|
||||
goto(`/admin/maerkte/${edited.target_id}`);
|
||||
} catch (err) {
|
||||
mergeError = err instanceof Error ? err.message : 'Merge fehlgeschlagen.';
|
||||
} finally {
|
||||
applying = false;
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabels: Record<EditionStatus, string> = {
|
||||
rumored: 'Ausstehend',
|
||||
confirmed: 'Bestätigt',
|
||||
active: 'Aktiv',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Abgesagt',
|
||||
archived: 'Archiviert'
|
||||
archived: 'Archiviert',
|
||||
merged: 'Zusammengeführt'
|
||||
};
|
||||
|
||||
const statusColors: Record<EditionStatus, string> = {
|
||||
@@ -25,7 +92,8 @@
|
||||
active: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
completed: 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-400',
|
||||
cancelled: 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-200',
|
||||
archived: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-500'
|
||||
archived: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-500',
|
||||
merged: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-400'
|
||||
};
|
||||
|
||||
const chfCountries = new Set(['CH', 'LI']);
|
||||
@@ -209,26 +277,31 @@
|
||||
{/if}
|
||||
|
||||
{#if data.duplicates && data.duplicates.length > 0}
|
||||
<div
|
||||
class="rounded-lg border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-950"
|
||||
>
|
||||
<h3 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
|
||||
Mögliche Duplikate gefunden
|
||||
</h3>
|
||||
<ul class="space-y-1">
|
||||
{#each data.duplicates as dup}
|
||||
<li class="text-sm text-amber-700 dark:text-amber-300">
|
||||
<a href="/admin/maerkte/{dup.id}" class="font-medium hover:underline">
|
||||
{dup.name}
|
||||
</a>
|
||||
— {dup.city}, {formatDate(dup.start_date)} - {formatDate(dup.end_date)}
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400">
|
||||
({Math.round(dup.similarity * 100)}% Ähnlichkeit)
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<DuplicatesPanel
|
||||
duplicates={data.duplicates}
|
||||
marketId={data.market.id}
|
||||
{planningId}
|
||||
onPlan={handlePlan}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if mergeError}
|
||||
<Alert variant="error">{mergeError}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if proposal && proposalCandidate}
|
||||
<MergeProposalPanel
|
||||
{proposal}
|
||||
candidate={proposalCandidate}
|
||||
current={data.market}
|
||||
{applying}
|
||||
onApply={applyMerge}
|
||||
onClose={() => {
|
||||
proposal = null;
|
||||
proposalCandidate = null;
|
||||
mergeError = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Status badge + review section -->
|
||||
|
||||
@@ -19,12 +19,18 @@
|
||||
const res = await fetch(`/admin/maerkte/${data.market.id}/bearbeiten/research-plan`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const body = await res.json();
|
||||
const text = await res.text();
|
||||
let body: { error?: string; [k: string]: unknown } = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
// non-JSON error body; keep body empty
|
||||
}
|
||||
if (!res.ok) {
|
||||
planError = body.error ?? 'Recherche-Plan fehlgeschlagen.';
|
||||
planError = body.error ?? `Recherche-Plan fehlgeschlagen (HTTP ${res.status}).`;
|
||||
return;
|
||||
}
|
||||
planResult = body as PlanResponse;
|
||||
planResult = body as unknown as PlanResponse;
|
||||
} catch (err) {
|
||||
planError = err instanceof Error ? err.message : 'Recherche-Plan fehlgeschlagen.';
|
||||
} finally {
|
||||
@@ -40,9 +46,15 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ research_result: planResult.research_result, fields })
|
||||
});
|
||||
const body = await res.json();
|
||||
const text = await res.text();
|
||||
let body: { error?: string; [k: string]: unknown } = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
// non-JSON error body; keep body empty
|
||||
}
|
||||
if (!res.ok) {
|
||||
planError = body.error ?? 'Anwenden fehlgeschlagen.';
|
||||
planError = body.error ?? `Anwenden fehlgeschlagen (HTTP ${res.status}).`;
|
||||
return;
|
||||
}
|
||||
planResult = null;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { serverFetch } from '$lib/api/client.server.js';
|
||||
import type { AdminMarketDetail, MarketMergeProposal } from '$lib/api/types.js';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, params, request }) => {
|
||||
try {
|
||||
const proposal = (await request.json()) as MarketMergeProposal;
|
||||
const res = await serverFetch<AdminMarketDetail>(
|
||||
`/admin/markets/${params.id}/merge-into/${params.targetId}`,
|
||||
cookies,
|
||||
{ method: 'POST', body: JSON.stringify({ proposal }) }
|
||||
);
|
||||
return json(res.data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Merge fehlgeschlagen.';
|
||||
return json({ error: message }, { status: 502 });
|
||||
}
|
||||
};
|
||||
19
web/src/routes/admin/maerkte/[id]/merge-plan/+server.ts
Normal file
19
web/src/routes/admin/maerkte/[id]/merge-plan/+server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { serverFetch } from '$lib/api/client.server.js';
|
||||
import type { MarketMergeProposal } 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 { target_id: string };
|
||||
const res = await serverFetch<MarketMergeProposal>(
|
||||
`/admin/markets/${params.id}/merge-plan`,
|
||||
cookies,
|
||||
{ method: 'POST', body: JSON.stringify(body) }
|
||||
);
|
||||
return json(res.data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Merge-Plan fehlgeschlagen.';
|
||||
return json({ error: message }, { status: 502 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user