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.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -61,6 +61,11 @@
|
||||
</svg>
|
||||
{formatDate(market.start_date)} – {formatDate(market.end_date)}
|
||||
</span>
|
||||
{#if market.edition_count && market.edition_count > 1}
|
||||
<span class="text-primary-600 dark:text-primary-400 text-xs font-medium">
|
||||
+{market.edition_count - 1} weitere {market.edition_count > 2 ? 'Termine' : 'Termin'}
|
||||
</span>
|
||||
{/if}
|
||||
{#if market.distance !== undefined}
|
||||
<span class="text-primary-600 dark:text-primary-400 flex items-center gap-1">
|
||||
<svg
|
||||
|
||||
@@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit';
|
||||
import { serverFetch } from '$lib/api/client.server.js';
|
||||
import { ApiClientError } from '$lib/api/client.js';
|
||||
import { buildSearchQuery } from '$lib/api/client.js';
|
||||
import type { AdminMarketSummary, PaginationMeta } from '$lib/api/types.js';
|
||||
import type { AdminSeriesGroup, PaginationMeta } from '$lib/api/types.js';
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
@@ -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<AdminMarketSummary[]>(`/admin/markets?${query}`, cookies);
|
||||
const res = await serverFetch<AdminSeriesGroup[]>(`/admin/markets/grouped?${query}`, cookies);
|
||||
|
||||
return {
|
||||
markets: res.data,
|
||||
groups: res.data,
|
||||
meta: res.meta as PaginationMeta,
|
||||
filters: { status, q, sort, order }
|
||||
};
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import type { MarketStatus } from '$lib/api/types.js';
|
||||
import type { EditionStatus } from '$lib/api/types.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let searchValue = $state(data.filters.q);
|
||||
let expandedSeries = $state(new Set<string>());
|
||||
|
||||
const statusLabels: Record<MarketStatus, string> = {
|
||||
pending: 'Ausstehend',
|
||||
approved: 'Genehmigt',
|
||||
rejected: 'Abgelehnt'
|
||||
const statusLabels: Record<EditionStatus, string> = {
|
||||
rumored: 'Ausstehend',
|
||||
confirmed: 'Bestätigt',
|
||||
active: 'Aktiv',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Abgesagt',
|
||||
archived: 'Archiviert'
|
||||
};
|
||||
|
||||
const statusColors: Record<MarketStatus, string> = {
|
||||
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<EditionStatus, string> = {
|
||||
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 tabs: { label: string; value: string }[] = [
|
||||
{ label: 'Alle', value: '' },
|
||||
{ label: 'Ausstehend', value: 'pending' },
|
||||
{ label: 'Genehmigt', value: 'approved' },
|
||||
{ label: 'Abgelehnt', value: 'rejected' }
|
||||
{ label: 'Ausstehend', value: 'rumored' },
|
||||
{ label: 'Bestätigt', value: 'confirmed' },
|
||||
{ label: 'Aktiv', value: 'active' },
|
||||
{ label: 'Abgeschlossen', value: 'completed' },
|
||||
{ label: 'Abgesagt', value: 'cancelled' }
|
||||
];
|
||||
|
||||
const currentStatus = $derived(page.url.searchParams.get('status') ?? '');
|
||||
const currentQ = $derived(page.url.searchParams.get('q') ?? '');
|
||||
const currentSort = $derived(page.url.searchParams.get('sort') ?? '');
|
||||
const currentOrder = $derived(page.url.searchParams.get('order') ?? '');
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
@@ -34,37 +49,51 @@
|
||||
|
||||
function buildUrl(overrides: Record<string, string> = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
const status = overrides.status ?? data.filters.status;
|
||||
const q = overrides.q ?? data.filters.q;
|
||||
const sort = overrides.sort ?? data.filters.sort;
|
||||
const order = overrides.order ?? data.filters.order;
|
||||
const page = overrides.page ?? '1';
|
||||
const status = overrides.status ?? currentStatus;
|
||||
const q = overrides.q ?? currentQ;
|
||||
const sort = overrides.sort ?? currentSort;
|
||||
const order = overrides.order ?? currentOrder;
|
||||
const pg = overrides.page ?? '1';
|
||||
|
||||
if (status) params.set('status', status);
|
||||
if (q) params.set('q', q);
|
||||
if (sort) params.set('sort', sort);
|
||||
if (order) params.set('order', order);
|
||||
if (page !== '1') params.set('page', page);
|
||||
if (pg !== '1') params.set('page', pg);
|
||||
|
||||
const qs = params.toString();
|
||||
return `/admin/maerkte${qs ? `?${qs}` : ''}`;
|
||||
}
|
||||
|
||||
function sortUrl(column: string): string {
|
||||
const isActive = data.filters.sort === column;
|
||||
const newOrder = isActive && data.filters.order !== 'asc' ? 'asc' : 'desc';
|
||||
return buildUrl({ sort: column, order: newOrder });
|
||||
const isActive = currentSort === column;
|
||||
const defaultAsc = column === 'name' || column === 'city';
|
||||
if (isActive) {
|
||||
const newOrder = currentOrder === 'asc' ? 'desc' : 'asc';
|
||||
return buildUrl({ sort: column, order: newOrder });
|
||||
}
|
||||
return buildUrl({ sort: column, order: defaultAsc ? 'asc' : 'desc' });
|
||||
}
|
||||
|
||||
function sortIndicator(column: string): string {
|
||||
if (data.filters.sort !== column) return '';
|
||||
return data.filters.order === 'asc' ? ' \u2191' : ' \u2193';
|
||||
if (currentSort !== column) return '';
|
||||
return currentOrder === 'asc' ? ' \u2191' : ' \u2193';
|
||||
}
|
||||
|
||||
function handleSearch(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
goto(buildUrl({ q: searchValue }));
|
||||
}
|
||||
|
||||
function toggleExpand(seriesId: string) {
|
||||
const next = new Set(expandedSeries);
|
||||
if (next.has(seriesId)) {
|
||||
next.delete(seriesId);
|
||||
} else {
|
||||
next.add(seriesId);
|
||||
}
|
||||
expandedSeries = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -91,7 +120,7 @@
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
/>
|
||||
<Button type="submit" variant="secondary" size="sm">Suchen</Button>
|
||||
{#if data.filters.q}
|
||||
{#if currentQ}
|
||||
<a href={buildUrl({ q: '' })}>
|
||||
<Button type="button" variant="secondary" size="sm">Zurücksetzen</Button>
|
||||
</a>
|
||||
@@ -105,7 +134,7 @@
|
||||
<a
|
||||
href={buildUrl({ status: tab.value })}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors
|
||||
{data.filters.status === tab.value
|
||||
{currentStatus === tab.value
|
||||
? 'bg-white text-stone-900 shadow-sm dark:bg-stone-700 dark:text-stone-100'
|
||||
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200'}"
|
||||
>
|
||||
@@ -114,11 +143,12 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Market table -->
|
||||
<!-- Market table (grouped by series) -->
|
||||
<div class="overflow-x-auto rounded-lg border border-stone-200 dark:border-stone-700">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-stone-50 dark:bg-stone-800">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-3"></th>
|
||||
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">
|
||||
<a href={sortUrl('name')} class="hover:text-stone-900 dark:hover:text-stone-100">
|
||||
Name{sortIndicator('name')}
|
||||
@@ -134,6 +164,7 @@
|
||||
Status{sortIndicator('status')}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300"> Jahr </th>
|
||||
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">
|
||||
<a href={sortUrl('date')} class="hover:text-stone-900 dark:hover:text-stone-100">
|
||||
Zeitraum{sortIndicator('date')}
|
||||
@@ -148,37 +179,72 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-stone-200 dark:divide-stone-700">
|
||||
{#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)}
|
||||
<!-- Primary row (latest edition) -->
|
||||
<tr class="hover:bg-stone-50 dark:hover:bg-stone-800/50">
|
||||
<td class="px-4 py-3 font-medium text-stone-900 dark:text-stone-100">
|
||||
{market.name}
|
||||
<td class="px-2 py-3 text-center">
|
||||
{#if hasMultiple}
|
||||
<button
|
||||
onclick={() => toggleExpand(group.series_id)}
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded text-stone-400 transition-colors hover:bg-stone-200 hover:text-stone-600 dark:hover:bg-stone-700 dark:hover:text-stone-300"
|
||||
aria-label={isExpanded ? 'Zuklappen' : 'Aufklappen'}
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 transition-transform {isExpanded ? 'rotate-90' : ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-stone-600 dark:text-stone-400">{market.city}</td>
|
||||
<td class="px-4 py-3 font-medium text-stone-900 dark:text-stone-100">
|
||||
{latest.name}
|
||||
{#if hasMultiple}
|
||||
<span class="text-primary-600 dark:text-primary-400 ml-1.5 text-xs font-normal">
|
||||
({group.editions.length} Ausgaben)
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-stone-600 dark:text-stone-400">{latest.city}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusColors[
|
||||
market.status
|
||||
latest.status
|
||||
]}"
|
||||
>
|
||||
{statusLabels[market.status]}
|
||||
{statusLabels[latest.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-stone-600 dark:text-stone-400">
|
||||
{formatDate(market.start_date)} - {formatDate(market.end_date)}
|
||||
<td class="px-4 py-3 text-stone-600 dark:text-stone-400">
|
||||
{latest.year}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-stone-600 dark:text-stone-400">
|
||||
{formatDate(market.created_at)}
|
||||
{formatDate(latest.start_date)} - {formatDate(latest.end_date)}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-stone-600 dark:text-stone-400">
|
||||
{formatDate(latest.created_at)}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
href="/admin/maerkte/{market.id}"
|
||||
href="/admin/maerkte/{latest.id}"
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
|
||||
>
|
||||
Ansehen
|
||||
</a>
|
||||
<a
|
||||
href="/admin/maerkte/{market.id}/bearbeiten"
|
||||
href="/admin/maerkte/{latest.id}/bearbeiten"
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
|
||||
>
|
||||
Bearbeiten
|
||||
@@ -186,9 +252,59 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Expanded sub-rows (older editions) -->
|
||||
{#if hasMultiple && isExpanded}
|
||||
{#each group.editions.slice(1) as edition}
|
||||
<tr
|
||||
class="bg-stone-50/50 hover:bg-stone-100 dark:bg-stone-800/30 dark:hover:bg-stone-800/50"
|
||||
>
|
||||
<td class="px-2 py-2"></td>
|
||||
<td class="px-4 py-2 pl-8 text-stone-600 dark:text-stone-400">
|
||||
{edition.name}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-stone-500 dark:text-stone-500">
|
||||
{edition.city}
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<span
|
||||
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusColors[
|
||||
edition.status
|
||||
]}"
|
||||
>
|
||||
{statusLabels[edition.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-stone-500 dark:text-stone-500">
|
||||
{edition.year}
|
||||
</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-stone-500 dark:text-stone-500">
|
||||
{formatDate(edition.start_date)} - {formatDate(edition.end_date)}
|
||||
</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-stone-500 dark:text-stone-500">
|
||||
{formatDate(edition.created_at)}
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
href="/admin/maerkte/{edition.id}"
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
|
||||
>
|
||||
Ansehen
|
||||
</a>
|
||||
<a
|
||||
href="/admin/maerkte/{edition.id}/bearbeiten"
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
|
||||
>
|
||||
Bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-stone-500 dark:text-stone-400">
|
||||
<td colspan="8" class="px-4 py-8 text-center text-stone-500 dark:text-stone-400">
|
||||
Keine Märkte gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -201,7 +317,7 @@
|
||||
{#if data.meta && data.meta.total_pages > 1}
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-stone-600 dark:text-stone-400">
|
||||
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)
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
{#if data.meta.page > 1}
|
||||
|
||||
@@ -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<DuplicateMarket[]>(
|
||||
`/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<SeriesEditionsResponse>(
|
||||
`/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<AdminMarketDetail>(
|
||||
`/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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<MarketStatus, string> = {
|
||||
pending: 'Ausstehend',
|
||||
approved: 'Genehmigt',
|
||||
rejected: 'Abgelehnt'
|
||||
const statusLabels: Record<EditionStatus, string> = {
|
||||
rumored: 'Ausstehend',
|
||||
confirmed: 'Bestätigt',
|
||||
active: 'Aktiv',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Abgesagt',
|
||||
archived: 'Archiviert'
|
||||
};
|
||||
|
||||
const statusColors: Record<MarketStatus, string> = {
|
||||
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<EditionStatus, string> = {
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -55,8 +67,48 @@
|
||||
← Zurück zur Liste
|
||||
</a>
|
||||
<h1 class="mt-1 text-2xl font-bold">{data.market.name}</h1>
|
||||
<p class="mt-0.5 text-sm text-stone-500 dark:text-stone-400">
|
||||
Edition {data.market.year}
|
||||
{#if data.market.series_name !== data.market.name}
|
||||
· Serie: {data.market.series_name}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#if data.editions && data.editions.length > 1}
|
||||
<details class="relative">
|
||||
<summary
|
||||
class="cursor-pointer rounded-lg border border-stone-300 px-3 py-1.5 text-sm font-medium text-stone-700 hover:bg-stone-50 dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-800"
|
||||
>
|
||||
{data.editions.length} Editionen
|
||||
</summary>
|
||||
<div
|
||||
class="absolute right-0 z-10 mt-1 min-w-48 rounded-lg border border-stone-200 bg-white p-2 shadow-lg dark:border-stone-700 dark:bg-stone-900"
|
||||
>
|
||||
{#each data.editions as ed}
|
||||
<a
|
||||
href="/admin/maerkte/{ed.id}"
|
||||
class="block rounded px-3 py-1.5 text-sm hover:bg-stone-100 dark:hover:bg-stone-800
|
||||
{ed.id === data.market.id
|
||||
? 'text-primary-600 dark:text-primary-400 font-semibold'
|
||||
: 'text-stone-700 dark:text-stone-300'}"
|
||||
>
|
||||
{ed.year}
|
||||
<span class="ml-1 rounded-full px-1.5 py-0.5 text-xs {statusColors[ed.status]}">
|
||||
{statusLabels[ed.status]}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-stone-300 px-3 py-1.5 text-sm font-medium text-stone-700 hover:bg-stone-50 dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-800"
|
||||
onclick={() => (showNewEdition = !showNewEdition)}
|
||||
>
|
||||
+ Neue Edition
|
||||
</button>
|
||||
<a href="/admin/maerkte/{data.market.id}/bearbeiten">
|
||||
<Button variant="secondary" size="sm">Bearbeiten</Button>
|
||||
</a>
|
||||
@@ -80,13 +132,79 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showNewEdition}
|
||||
<div
|
||||
class="border-primary-200 bg-primary-50 dark:border-primary-800 dark:bg-primary-950 rounded-lg border p-4"
|
||||
>
|
||||
<h3 class="text-primary-800 dark:text-primary-200 mb-3 text-sm font-semibold">
|
||||
Neue Edition für "{data.market.series_name || data.market.name}"
|
||||
</h3>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/createEdition"
|
||||
class="flex flex-wrap items-end gap-3"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="series_id" value={data.market.series_id} />
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="new_start_date"
|
||||
class="block text-xs font-medium text-stone-700 dark:text-stone-300"
|
||||
>
|
||||
Startdatum
|
||||
</label>
|
||||
<input
|
||||
id="new_start_date"
|
||||
type="date"
|
||||
name="start_date"
|
||||
required
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 rounded-lg border border-stone-300 px-3 py-1.5
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="new_end_date"
|
||||
class="block text-xs font-medium text-stone-700 dark:text-stone-300"
|
||||
>
|
||||
Enddatum
|
||||
</label>
|
||||
<input
|
||||
id="new_end_date"
|
||||
type="date"
|
||||
name="end_date"
|
||||
required
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 rounded-lg border border-stone-300 px-3 py-1.5
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm" {loading}>Anlegen</Button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200"
|
||||
onclick={() => (showNewEdition = false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error}
|
||||
<Alert variant="error">{form.error}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<Alert variant="success">
|
||||
Status erfolgreich auf "{form.action === 'approved' ? 'Genehmigt' : 'Abgelehnt'}" geändert.
|
||||
Status erfolgreich auf "{form.action === 'approved' ? 'Bestätigt' : 'Abgesagt'}" geändert.
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
@@ -126,7 +244,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if data.market.status === 'pending'}
|
||||
{#if isReviewable}
|
||||
<div class="mt-4 border-t border-stone-200 pt-4 dark:border-stone-700">
|
||||
<form
|
||||
method="POST"
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { apiFetch, ApiClientError } from '$lib/api/client.js';
|
||||
import type { MarketDetail } from '$lib/api/types.js';
|
||||
import type { MarketDetail, EditionBrief } from '$lib/api/types.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
export const load: PageServerLoad = async ({ params, url, fetch }) => {
|
||||
const year = url.searchParams.get('year') ?? '';
|
||||
const query = year ? `?year=${year}` : '';
|
||||
|
||||
try {
|
||||
const res = await apiFetch<MarketDetail>(`/markets/${params.slug}`, { fetch });
|
||||
return { market: res.data };
|
||||
const res = await apiFetch<MarketDetail>(`/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.' });
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<script lang="ts">
|
||||
import MarketMap from '$lib/components/market/MarketMap.svelte';
|
||||
import type { MarketDetail, OpeningHoursEntry, AdmissionInfo } from '$lib/api/types.js';
|
||||
import type {
|
||||
MarketDetail,
|
||||
OpeningHoursEntry,
|
||||
AdmissionInfo,
|
||||
EditionBrief
|
||||
} from '$lib/api/types.js';
|
||||
import { stateToSlug, toSlug } from '$lib/utils/slug.js';
|
||||
|
||||
let { data } = $props();
|
||||
const market: MarketDetail = $derived(data.market);
|
||||
const editions: EditionBrief[] = $derived(data.editions);
|
||||
const currentYear = $derived(new Date(market.start_date).getFullYear());
|
||||
const hasMultipleEditions = $derived(editions.length > 1);
|
||||
const stateSlug = $derived(stateToSlug(market.state));
|
||||
const citySlug = $derived(toSlug(market.city));
|
||||
|
||||
@@ -281,6 +289,26 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if hasMultipleEditions}
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<span class="text-sm text-stone-500 dark:text-stone-400">Ausgabe:</span>
|
||||
<div class="flex gap-1">
|
||||
{#each editions as edition}
|
||||
{@const isActive = edition.year === currentYear}
|
||||
<a
|
||||
href="/markt/{market.slug}{isActive ? '' : `?year=${edition.year}`}"
|
||||
class="rounded-md px-2.5 py-1 text-sm font-medium transition-colors
|
||||
{isActive
|
||||
? 'bg-primary-600 dark:bg-primary-500 text-white'
|
||||
: 'bg-stone-100 text-stone-600 hover:bg-stone-200 dark:bg-stone-800 dark:text-stone-400 dark:hover:bg-stone-700'}"
|
||||
>
|
||||
{edition.year}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if market.organizer_name}
|
||||
<p class="mt-2 text-sm text-stone-500 dark:text-stone-400">
|
||||
Veranstalter: {market.organizer_name}
|
||||
|
||||
Reference in New Issue
Block a user