From 6f8df87f80789253ff975257e8f1368804a45373 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 5 Mar 2026 15:17:59 +0100 Subject: [PATCH 1/3] feat: add AI research, geocoding, enriched market forms and public submit upgrade - AI research panel with structured display (opening hours, admission) - Shared MarketForm component with admin/public mode - Geocode button to derive coordinates from address - Opening hours, admission info fields in all forms - Currency-aware pricing, full European country list - Public submit now includes all market fields - Weekday normalization from date for AI research results - Duplicate detection warning on admin detail view --- web/src/lib/api/types.ts | 24 + .../lib/components/admin/MarketForm.svelte | 457 +++++++++++++++++- .../lib/components/admin/ResearchPanel.svelte | 186 +++++++ .../routes/admin/maerkte/[id]/+page.server.ts | 19 +- .../routes/admin/maerkte/[id]/+page.svelte | 102 +++- .../maerkte/[id]/bearbeiten/+page.server.ts | 32 +- .../maerkte/[id]/bearbeiten/+page.svelte | 113 ++++- .../routes/admin/maerkte/neu/+page.server.ts | 14 +- web/src/routes/api/geocode/+server.ts | 19 + .../routes/markt/einreichen/+page.server.ts | 55 ++- web/src/routes/markt/einreichen/+page.svelte | 260 +++------- 11 files changed, 1023 insertions(+), 258 deletions(-) create mode 100644 web/src/lib/components/admin/ResearchPanel.svelte create mode 100644 web/src/routes/api/geocode/+server.ts diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 40d3b99..2b7f597 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -160,6 +160,30 @@ export interface SubmitMarketRequest { turnstile_token: string; } +// AI Research types +export interface ResearchResult { + suggestions: FieldSuggestion[]; + sources: string[]; +} + +export interface FieldSuggestion { + field: string; + current_value: unknown; + suggested_value: unknown; + confidence: 'high' | 'medium' | 'low'; + reason: string; +} + +// Duplicate detection +export interface DuplicateMarket { + id: string; + name: string; + city: string; + start_date: string; + end_date: string; + similarity: number; +} + // Search params (mirrors backend SearchParams) export interface MarketSearchParams { lat?: number; diff --git a/web/src/lib/components/admin/MarketForm.svelte b/web/src/lib/components/admin/MarketForm.svelte index b4be2e9..014dfaa 100644 --- a/web/src/lib/components/admin/MarketForm.svelte +++ b/web/src/lib/components/admin/MarketForm.svelte @@ -1,15 +1,129 @@ {#if error} @@ -26,7 +140,14 @@
Allgemein - +
@@ -46,15 +169,40 @@
Standort - +
- - + +
- +
+ +
+ + {#if geocodeError} + {geocodeError} + {/if} +
@@ -118,22 +322,231 @@
Weitere Infos - +
+
+ Öffnungszeiten + + {#each hours as row, i} +
+
+ + +
+ { + row.open = e.currentTarget.value; + }} + /> + { + row.close = e.currentTarget.value; + }} + /> + +
+ {/each} + + + + +
+ +
+ Eintrittspreise + +
+
+ + { + admission.adult_cents = Math.round(parseFloat(e.currentTarget.value || '0') * 100); + }} + class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2 + text-sm shadow-sm focus:ring-2 focus:outline-none + dark:border-stone-600 dark:bg-stone-800" + /> +
+
+ + { + admission.child_cents = Math.round(parseFloat(e.currentTarget.value || '0') * 100); + }} + class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2 + text-sm shadow-sm focus:ring-2 focus:outline-none + dark:border-stone-600 dark:bg-stone-800" + /> +
+
+ + { + admission.reduced_cents = Math.round(parseFloat(e.currentTarget.value || '0') * 100); + }} + class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2 + text-sm shadow-sm focus:ring-2 focus:outline-none + dark:border-stone-600 dark:bg-stone-800" + /> +
+
+ +
+
+ + +
+
+ +
+ + +
+ + +
+ + {#if mode === 'admin'} +
+ Admin-Notizen + +
+ +
+
+ {/if} + + {#if extraFields} + {@render extraFields()} + {/if} +
- - - - + + {#if mode === 'admin'} + + + + {/if}
diff --git a/web/src/lib/components/admin/ResearchPanel.svelte b/web/src/lib/components/admin/ResearchPanel.svelte new file mode 100644 index 0000000..e82460d --- /dev/null +++ b/web/src/lib/components/admin/ResearchPanel.svelte @@ -0,0 +1,186 @@ + + +
+
+

+ KI-Recherche Ergebnisse +

+ +
+ + {#if result.suggestions.length === 0} +

Keine Vorschläge gefunden.

+ {:else} +
+ {#each result.suggestions as suggestion, i} + + {/each} +
+ +
+ + +
+ {/if} + + {#if result.sources && result.sources.length > 0} +
+

Quellen:

+ +
+ {/if} +
diff --git a/web/src/routes/admin/maerkte/[id]/+page.server.ts b/web/src/routes/admin/maerkte/[id]/+page.server.ts index c468607..7b35454 100644 --- a/web/src/routes/admin/maerkte/[id]/+page.server.ts +++ b/web/src/routes/admin/maerkte/[id]/+page.server.ts @@ -1,11 +1,26 @@ import { fail } from '@sveltejs/kit'; import { serverFetch } from '$lib/api/client.server.js'; -import type { AdminMarketDetail } from '$lib/api/types.js'; +import type { AdminMarketDetail, DuplicateMarket } from '$lib/api/types.js'; import type { Actions, PageServerLoad } from './$types.js'; export const load: PageServerLoad = async ({ params, cookies }) => { const res = await serverFetch(`/admin/markets/${params.id}`, cookies); - return { market: res.data }; + const market = res.data; + + let duplicates: DuplicateMarket[] = []; + if (market.status === 'pending') { + try { + const dupRes = await serverFetch( + `/admin/markets/${params.id}/duplicates`, + cookies + ); + duplicates = dupRes.data ?? []; + } catch { + // Non-fatal: duplicates are informational + } + } + + return { market, duplicates }; }; export const actions: Actions = { diff --git a/web/src/routes/admin/maerkte/[id]/+page.svelte b/web/src/routes/admin/maerkte/[id]/+page.svelte index ed1b807..2db9bad 100644 --- a/web/src/routes/admin/maerkte/[id]/+page.svelte +++ b/web/src/routes/admin/maerkte/[id]/+page.svelte @@ -21,6 +21,13 @@ rejected: 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-200' }; + const chfCountries = new Set(['CH', 'LI']); + const currency = $derived(chfCountries.has(data.market.country) ? 'CHF' : 'EUR'); + + function formatPrice(cents: number): string { + return (cents / 100).toFixed(2) + ' ' + currency; + } + function formatDate(dateStr: string): string { if (!dateStr) return '-'; const d = new Date(dateStr); @@ -83,6 +90,29 @@ {/if} + {#if data.duplicates && data.duplicates.length > 0} +
+

+ Mögliche Duplikate gefunden +

+
    + {#each data.duplicates as dup} +
  • + + {dup.name} + + — {dup.city}, {formatDate(dup.start_date)} - {formatDate(dup.end_date)} + + ({Math.round(dup.similarity * 100)}% Ähnlichkeit) + +
  • + {/each} +
+
+ {/if} +
@@ -173,7 +203,7 @@
Koordinaten
- {data.market.latitude.toFixed(6)}, {data.market.longitude.toFixed(6)} + {data.market.latitude?.toFixed(6) ?? '—'}, {data.market.longitude?.toFixed(6) ?? '—'}
@@ -214,6 +244,76 @@
+ + {#if data.market.opening_hours && data.market.opening_hours.length > 0} +
+

Öffnungszeiten

+ + + + + + + + + + {#each data.market.opening_hours as entry} + + + + + + {/each} + +
TagVonBis
{entry.day}{entry.open}{entry.close}
+
+ {/if} + + + {#if data.market.admission_info && (data.market.admission_info.adult_cents > 0 || data.market.admission_info.child_cents > 0 || data.market.admission_info.notes)} +
+

Eintrittspreise

+
+
+
Erwachsene
+
+ {formatPrice(data.market.admission_info.adult_cents)} +
+
+
+
Kinder
+
+ {formatPrice(data.market.admission_info.child_cents)} +
+
+
+
Ermäßigt
+
+ {formatPrice(data.market.admission_info.reduced_cents)} +
+
+ {#if data.market.admission_info.free_under_age > 0} +
+
+ Frei unter (Alter) +
+
+ {data.market.admission_info.free_under_age} Jahre +
+
+ {/if} + {#if data.market.admission_info.notes} +
+
Hinweise
+
+ {data.market.admission_info.notes} +
+
+ {/if} +
+
+ {/if} + {#if data.market.submitter_email || data.market.submitter_name}
diff --git a/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.server.ts b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.server.ts index fa7bc77..a62750c 100644 --- a/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.server.ts +++ b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.server.ts @@ -1,6 +1,6 @@ import { fail, redirect } from '@sveltejs/kit'; import { serverFetch } from '$lib/api/client.server.js'; -import type { AdminMarketDetail } from '$lib/api/types.js'; +import type { AdminMarketDetail, ResearchResult } from '$lib/api/types.js'; import type { Actions, PageServerLoad } from './$types.js'; export const load: PageServerLoad = async ({ params, cookies }) => { @@ -9,7 +9,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => { }; export const actions: Actions = { - default: async ({ request, params, cookies, fetch }) => { + save: async ({ request, params, cookies, fetch }) => { const form = await request.formData(); const body: Record = {}; @@ -26,7 +26,8 @@ export const actions: Actions = { 'end_date', 'website', 'organizer_name', - 'image_url' + 'image_url', + 'admin_notes' ]; for (const field of strFields) { @@ -41,6 +42,17 @@ export const actions: Actions = { if (lat) body.latitude = parseFloat(lat); if (lon) body.longitude = parseFloat(lon); + const openingHoursRaw = form.get('opening_hours')?.toString(); + if (openingHoursRaw) { + const parsed = JSON.parse(openingHoursRaw); + body.opening_hours = Array.isArray(parsed) && parsed.length > 0 ? parsed : []; + } + + const admissionInfoRaw = form.get('admission_info')?.toString(); + if (admissionInfoRaw) { + body.admission_info = JSON.parse(admissionInfoRaw); + } + try { await serverFetch(`/admin/markets/${params.id}`, cookies, { method: 'PUT', @@ -54,5 +66,19 @@ export const actions: Actions = { const message = err instanceof Error ? err.message : 'Speichern fehlgeschlagen.'; return fail(500, { error: message }); } + }, + + research: async ({ params, cookies, fetch }) => { + try { + const res = await serverFetch( + `/admin/markets/${params.id}/research`, + cookies, + { method: 'POST', fetch } + ); + return { research: res.data }; + } catch (err) { + const message = err instanceof Error ? err.message : 'KI-Recherche fehlgeschlagen.'; + return fail(500, { error: message }); + } } }; diff --git a/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte index ec6279c..080e714 100644 --- a/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte +++ b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte @@ -1,9 +1,79 @@ @@ -11,18 +81,45 @@
-
- +
+ + ← Zurück zum Markt + +

Markt bearbeiten

+
+
{ + researching = true; + return async ({ update }) => { + researching = false; + await update(); + }; + }} > - ← Zurück zum Markt - -

Markt bearbeiten

+ +
+ {#if researchResult} + { + researchResult = null; + dismissed = true; + }} + /> + {/if} +
{ loading = true; return async ({ update }) => { @@ -31,6 +128,6 @@ }; }} > - +
diff --git a/web/src/routes/admin/maerkte/neu/+page.server.ts b/web/src/routes/admin/maerkte/neu/+page.server.ts index 706fa76..76bb5db 100644 --- a/web/src/routes/admin/maerkte/neu/+page.server.ts +++ b/web/src/routes/admin/maerkte/neu/+page.server.ts @@ -19,7 +19,8 @@ export const actions: Actions = { end_date: form.get('end_date')?.toString().trim() ?? '', website: form.get('website')?.toString().trim() ?? '', organizer_name: form.get('organizer_name')?.toString().trim() ?? '', - image_url: form.get('image_url')?.toString().trim() ?? '' + image_url: form.get('image_url')?.toString().trim() ?? '', + admin_notes: form.get('admin_notes')?.toString().trim() ?? '' }; const lat = form.get('latitude')?.toString(); @@ -27,6 +28,17 @@ export const actions: Actions = { body.latitude = lat ? parseFloat(lat) : 0; body.longitude = lon ? parseFloat(lon) : 0; + const openingHoursRaw = form.get('opening_hours')?.toString(); + if (openingHoursRaw) { + const parsed = JSON.parse(openingHoursRaw); + body.opening_hours = Array.isArray(parsed) && parsed.length > 0 ? parsed : []; + } + + const admissionInfoRaw = form.get('admission_info')?.toString(); + if (admissionInfoRaw) { + body.admission_info = JSON.parse(admissionInfoRaw); + } + try { const res = await serverFetch('/admin/markets', cookies, { method: 'POST', diff --git a/web/src/routes/api/geocode/+server.ts b/web/src/routes/api/geocode/+server.ts new file mode 100644 index 0000000..fdaafcd --- /dev/null +++ b/web/src/routes/api/geocode/+server.ts @@ -0,0 +1,19 @@ +import { json } from '@sveltejs/kit'; +import { apiFetch } from '$lib/api/client.js'; +import type { RequestHandler } from './$types.js'; + +export const POST: RequestHandler = async ({ request, fetch }) => { + const body = await request.json(); + + try { + const res = await apiFetch<{ latitude: number | null; longitude: number | null }>('/geocode', { + method: 'POST', + body: JSON.stringify(body), + fetch + }); + return json(res.data); + } catch (err) { + const message = err instanceof Error ? err.message : 'Geocoding failed'; + return json({ error: { message } }, { status: 500 }); + } +}; diff --git a/web/src/routes/markt/einreichen/+page.server.ts b/web/src/routes/markt/einreichen/+page.server.ts index b3284aa..53e7015 100644 --- a/web/src/routes/markt/einreichen/+page.server.ts +++ b/web/src/routes/markt/einreichen/+page.server.ts @@ -15,6 +15,7 @@ export const actions: Actions = { const name = form.get('name')?.toString().trim() ?? ''; const description = form.get('description')?.toString().trim() ?? ''; + const street = form.get('street')?.toString().trim() ?? ''; const latRaw = form.get('latitude')?.toString().trim() ?? ''; const lonRaw = form.get('longitude')?.toString().trim() ?? ''; const latitude = latRaw ? parseFloat(latRaw) : undefined; @@ -27,6 +28,7 @@ export const actions: Actions = { const endDate = form.get('end_date')?.toString().trim() ?? ''; const website = form.get('website')?.toString().trim() ?? ''; const organizerName = form.get('organizer_name')?.toString().trim() ?? ''; + const imageUrl = form.get('image_url')?.toString().trim() ?? ''; const submitterEmail = form.get('submitter_email')?.toString().trim() ?? ''; const submitterName = form.get('submitter_name')?.toString().trim() ?? ''; const turnstileToken = form.get('cf-turnstile-response')?.toString() ?? ''; @@ -34,6 +36,7 @@ export const actions: Actions = { const formState = { name, description, + street, latitude: latRaw, longitude: lonRaw, city, @@ -44,6 +47,7 @@ export const actions: Actions = { endDate, website, organizerName, + imageUrl, submitterEmail, submitterName }; @@ -56,25 +60,44 @@ export const actions: Actions = { return fail(400, { error: 'Bitte bestaetige die Spam-Pruefung.', ...formState }); } + const body: Record = { + name, + description, + street, + city, + state, + zip, + country, + start_date: startDate, + end_date: endDate, + website, + organizer_name: organizerName, + image_url: imageUrl, + submitter_email: submitterEmail, + submitter_name: submitterName, + turnstile_token: turnstileToken + }; + + if (latitude !== undefined && longitude !== undefined) { + body.latitude = latitude; + body.longitude = longitude; + } + + const openingHoursRaw = form.get('opening_hours')?.toString(); + if (openingHoursRaw) { + const parsed = JSON.parse(openingHoursRaw); + body.opening_hours = Array.isArray(parsed) && parsed.length > 0 ? parsed : null; + } + + const admissionInfoRaw = form.get('admission_info')?.toString(); + if (admissionInfoRaw) { + body.admission_info = JSON.parse(admissionInfoRaw); + } + try { await apiFetch('/markets/submit', { method: 'POST', - body: JSON.stringify({ - name, - description, - ...(latitude !== undefined && longitude !== undefined ? { latitude, longitude } : {}), - city, - state, - zip, - country, - start_date: startDate, - end_date: endDate, - website, - organizer_name: organizerName, - submitter_email: submitterEmail, - submitter_name: submitterName, - turnstile_token: turnstileToken - }), + body: JSON.stringify(body), fetch }); diff --git a/web/src/routes/markt/einreichen/+page.svelte b/web/src/routes/markt/einreichen/+page.svelte index 626d3bb..ddf4484 100644 --- a/web/src/routes/markt/einreichen/+page.svelte +++ b/web/src/routes/markt/einreichen/+page.svelte @@ -1,7 +1,6 @@ @@ -42,11 +79,31 @@
+ +
+
+ + + {#if data.filters.q} + + + + {/if} +
+
+
{#each tabs as tab} - Name - Stadt - Status - Zeitraum - Erstellt + + + Name{sortIndicator('name')} + + + + + Stadt{sortIndicator('city')} + + + + + Status{sortIndicator('status')} + + + + + Zeitraum{sortIndicator('date')} + + + + + Erstellt{sortIndicator('created')} + + Aktionen @@ -128,22 +205,12 @@

{#if data.meta.page > 1} - + {/if} {#if data.meta.page < data.meta.total_pages} - + {/if} From 5ea6afca3e33bd00328c86f66c6fde3ecdd543d7 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 5 Mar 2026 18:14:47 +0100 Subject: [PATCH 3/3] feat: group market editions by series in search and admin list Public search now deduplicates to one card per series with an edition count badge. Admin list uses a grouped endpoint with expandable rows. Market detail page shows an edition year switcher when multiple editions exist. Admin detail page includes series edition management. --- web/src/lib/api/types.ts | 51 ++++- .../lib/components/market/MarketCard.svelte | 5 + web/src/routes/admin/maerkte/+page.server.ts | 6 +- web/src/routes/admin/maerkte/+page.svelte | 194 ++++++++++++++---- .../routes/admin/maerkte/[id]/+page.server.ts | 57 ++++- .../routes/admin/maerkte/[id]/+page.svelte | 140 ++++++++++++- web/src/routes/markt/[slug]/+page.server.ts | 14 +- web/src/routes/markt/[slug]/+page.svelte | 30 ++- 8 files changed, 433 insertions(+), 64 deletions(-) diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 2b7f597..98dd3cd 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -20,6 +20,7 @@ export interface PaginationMeta { // Market types export interface MarketSummary { id: string; + series_id?: string; slug: string; name: string; city: string; @@ -33,6 +34,7 @@ export interface MarketSummary { image_url: string; organizer_name: string; distance?: number; // meters, only in geo queries + edition_count?: number; } export interface MarketDetail { @@ -70,6 +72,12 @@ export interface AdmissionInfo { notes: string; } +export interface EditionBrief { + year: number; + start_date: string; + end_date: string; +} + // Auth types export interface AuthData { access_token: string; @@ -99,15 +107,26 @@ export interface ProfileData { } // Admin types -export type MarketStatus = 'pending' | 'approved' | 'rejected'; +export type EditionStatus = + | 'rumored' + | 'confirmed' + | 'active' + | 'completed' + | 'cancelled' + | 'archived'; + +// Keep backward compat alias +export type MarketStatus = EditionStatus; export interface AdminMarketSummary { id: string; + series_id: string; + year: number; slug: string; name: string; city: string; state: string; - status: MarketStatus; + status: EditionStatus; start_date: string; end_date: string; organizer_name: string; @@ -117,7 +136,10 @@ export interface AdminMarketSummary { export interface AdminMarketDetail { id: string; + series_id: string; + year: number; slug: string; + series_name: string; name: string; description: string; street: string; @@ -134,7 +156,8 @@ export interface AdminMarketDetail { website: string; organizer_name: string; image_url: string; - status: MarketStatus; + sources: string[] | null; + status: EditionStatus; submitter_email?: string; submitter_name: string; admin_notes: string; @@ -184,6 +207,28 @@ export interface DuplicateMarket { similarity: number; } +// Series types +export interface SeriesSummary { + id: string; + slug: string; + name: string; + city: string; + country: string; +} + +export interface AdminSeriesGroup { + series_id: string; + slug: string; + series_name: string; + city: string; + editions: AdminMarketSummary[]; +} + +export interface SeriesEditionsResponse { + series: SeriesSummary; + editions: AdminMarketSummary[]; +} + // Search params (mirrors backend SearchParams) export interface MarketSearchParams { lat?: number; diff --git a/web/src/lib/components/market/MarketCard.svelte b/web/src/lib/components/market/MarketCard.svelte index 6acb769..40f7263 100644 --- a/web/src/lib/components/market/MarketCard.svelte +++ b/web/src/lib/components/market/MarketCard.svelte @@ -61,6 +61,11 @@ {formatDate(market.start_date)} – {formatDate(market.end_date)} + {#if market.edition_count && market.edition_count > 1} + + +{market.edition_count - 1} weitere {market.edition_count > 2 ? 'Termine' : 'Termin'} + + {/if} {#if market.distance !== undefined} { @@ -15,10 +15,10 @@ export const load: PageServerLoad = async ({ url, cookies }) => { const query = buildSearchQuery({ status, q, page, per_page: '20', sort, order }); try { - const res = await serverFetch(`/admin/markets?${query}`, cookies); + const res = await serverFetch(`/admin/markets/grouped?${query}`, cookies); return { - markets: res.data, + groups: res.data, meta: res.meta as PaginationMeta, filters: { status, q, sort, order } }; diff --git a/web/src/routes/admin/maerkte/+page.svelte b/web/src/routes/admin/maerkte/+page.svelte index 4892918..5188bd1 100644 --- a/web/src/routes/admin/maerkte/+page.svelte +++ b/web/src/routes/admin/maerkte/+page.svelte @@ -1,31 +1,46 @@ @@ -91,7 +120,7 @@ dark:border-stone-600 dark:bg-stone-800" /> - {#if data.filters.q} + {#if currentQ} @@ -105,7 +134,7 @@ @@ -114,11 +143,12 @@ {/each}
- +
+ + - {#each data.markets as market} + {#each data.groups as group} + {@const latest = group.editions[0]} + {@const hasMultiple = group.editions.length > 1} + {@const isExpanded = expandedSeries.has(group.series_id)} + - - + + - + + + {#if hasMultiple && isExpanded} + {#each group.editions.slice(1) as edition} + + + + + + + + + + + {/each} + {/if} {:else} - @@ -201,7 +317,7 @@ {#if data.meta && data.meta.total_pages > 1}

- Seite {data.meta.page} von {data.meta.total_pages} ({data.meta.total} Einträge) + Seite {data.meta.page} von {data.meta.total_pages} ({data.meta.total} Serien)

{#if data.meta.page > 1} diff --git a/web/src/routes/admin/maerkte/[id]/+page.server.ts b/web/src/routes/admin/maerkte/[id]/+page.server.ts index 7b35454..0fa3bb7 100644 --- a/web/src/routes/admin/maerkte/[id]/+page.server.ts +++ b/web/src/routes/admin/maerkte/[id]/+page.server.ts @@ -1,6 +1,11 @@ import { fail } from '@sveltejs/kit'; import { serverFetch } from '$lib/api/client.server.js'; -import type { AdminMarketDetail, DuplicateMarket } from '$lib/api/types.js'; +import type { + AdminMarketDetail, + DuplicateMarket, + EditionStatus, + SeriesEditionsResponse +} from '$lib/api/types.js'; import type { Actions, PageServerLoad } from './$types.js'; export const load: PageServerLoad = async ({ params, cookies }) => { @@ -8,7 +13,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => { const market = res.data; let duplicates: DuplicateMarket[] = []; - if (market.status === 'pending') { + if (market.status === 'rumored') { try { const dupRes = await serverFetch( `/admin/markets/${params.id}/duplicates`, @@ -20,7 +25,25 @@ export const load: PageServerLoad = async ({ params, cookies }) => { } } - return { market, duplicates }; + // Fetch other editions for this series + let editions: { id: string; year: number; status: EditionStatus }[] = []; + try { + const edRes = await serverFetch( + `/admin/series/${market.series_id}/editions`, + cookies + ); + // The response wraps editions inside { series, editions } + const raw = edRes.data as unknown as SeriesEditionsResponse; + editions = raw.editions.map((e) => ({ + id: e.id, + year: e.year, + status: e.status as EditionStatus + })); + } catch { + // Non-fatal + } + + return { market, duplicates, editions }; }; export const actions: Actions = { @@ -59,5 +82,33 @@ export const actions: Actions = { const message = err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.'; return fail(500, { error: message }); } + }, + + createEdition: async ({ request, cookies, fetch }) => { + const form = await request.formData(); + const seriesId = form.get('series_id')?.toString() ?? ''; + const startDate = form.get('start_date')?.toString() ?? ''; + const endDate = form.get('end_date')?.toString() ?? ''; + + if (!seriesId || !startDate || !endDate) { + return fail(400, { error: 'Start- und Enddatum sind erforderlich.' }); + } + + try { + const res = await serverFetch( + `/admin/series/${seriesId}/editions`, + cookies, + { + method: 'POST', + body: JSON.stringify({ start_date: startDate, end_date: endDate }), + fetch + } + ); + + return { created: true, newEditionId: res.data.id }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Edition erstellen fehlgeschlagen.'; + return fail(500, { error: message }); + } } }; diff --git a/web/src/routes/admin/maerkte/[id]/+page.svelte b/web/src/routes/admin/maerkte/[id]/+page.svelte index 2db9bad..c982982 100644 --- a/web/src/routes/admin/maerkte/[id]/+page.svelte +++ b/web/src/routes/admin/maerkte/[id]/+page.svelte @@ -3,22 +3,29 @@ import { goto } from '$app/navigation'; import Button from '$lib/components/ui/Button.svelte'; import Alert from '$lib/components/ui/Alert.svelte'; - import type { MarketStatus } from '$lib/api/types.js'; + import type { EditionStatus } from '$lib/api/types.js'; let { data, form } = $props(); let loading = $state(false); + let showNewEdition = $state(false); - const statusLabels: Record = { - pending: 'Ausstehend', - approved: 'Genehmigt', - rejected: 'Abgelehnt' + const statusLabels: Record = { + rumored: 'Ausstehend', + confirmed: 'Bestätigt', + active: 'Aktiv', + completed: 'Abgeschlossen', + cancelled: 'Abgesagt', + archived: 'Archiviert' }; - const statusColors: Record = { - pending: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', - approved: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', - rejected: 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-200' + const statusColors: Record = { + rumored: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', + confirmed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + 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' }; const chfCountries = new Set(['CH', 'LI']); @@ -34,10 +41,15 @@ return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); } + const isReviewable = $derived(data.market.status === 'rumored'); + $effect(() => { if (form?.deleted) { goto('/admin/maerkte'); } + if (form?.created && form?.newEditionId) { + goto(`/admin/maerkte/${form.newEditionId}`); + } }); @@ -55,8 +67,48 @@ ← Zurück zur Liste

{data.market.name}

+

+ Edition {data.market.year} + {#if data.market.series_name !== data.market.name} + · Serie: {data.market.series_name} + {/if} +

+ {#if data.editions && data.editions.length > 1} +
+ + {data.editions.length} Editionen + +
+ {#each data.editions as ed} + + {ed.year} + + {statusLabels[ed.status]} + + + {/each} +
+
+ {/if} + @@ -80,13 +132,79 @@
+ {#if showNewEdition} +
+

+ Neue Edition für "{data.market.series_name || data.market.name}" +

+
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + > + +
+ + +
+
+ + +
+ + + +
+ {/if} + {#if form?.error} {form.error} {/if} {#if form?.success} - Status erfolgreich auf "{form.action === 'approved' ? 'Genehmigt' : 'Abgelehnt'}" geändert. + Status erfolgreich auf "{form.action === 'approved' ? 'Bestätigt' : 'Abgesagt'}" geändert. {/if} @@ -126,7 +244,7 @@ - {#if data.market.status === 'pending'} + {#if isReviewable}
{ +export const load: PageServerLoad = async ({ params, url, fetch }) => { + const year = url.searchParams.get('year') ?? ''; + const query = year ? `?year=${year}` : ''; + try { - const res = await apiFetch(`/markets/${params.slug}`, { fetch }); - return { market: res.data }; + const res = await apiFetch(`/markets/${params.slug}${query}`, { fetch }); + return { + market: res.data, + editions: (res as unknown as { editions?: EditionBrief[] }).editions ?? [] + }; } catch (e) { if (e instanceof ApiClientError && e.status === 404) { error(404, { message: 'Markt nicht gefunden.' }); diff --git a/web/src/routes/markt/[slug]/+page.svelte b/web/src/routes/markt/[slug]/+page.svelte index 4abe5f0..6faa86c 100644 --- a/web/src/routes/markt/[slug]/+page.svelte +++ b/web/src/routes/markt/[slug]/+page.svelte @@ -1,10 +1,18 @@
Name{sortIndicator('name')} @@ -134,6 +164,7 @@ Status{sortIndicator('status')} Jahr Zeitraum{sortIndicator('date')} @@ -148,37 +179,72 @@
- {market.name} + + {#if hasMultiple} + + {/if} {market.city} + {latest.name} + {#if hasMultiple} + + ({group.editions.length} Ausgaben) + + {/if} + {latest.city} - {statusLabels[market.status]} + {statusLabels[latest.status]} - {formatDate(market.start_date)} - {formatDate(market.end_date)} + + {latest.year} - {formatDate(market.created_at)} + {formatDate(latest.start_date)} - {formatDate(latest.end_date)} + + {formatDate(latest.created_at)}
+ {edition.name} + + {edition.city} + + + {statusLabels[edition.status]} + + + {edition.year} + + {formatDate(edition.start_date)} - {formatDate(edition.end_date)} + + {formatDate(edition.created_at)} + + +
+ Keine Märkte gefunden.