feat(admin): filter markets by missing fields + row indicators
- Add missing= query param (description/image/website/location) to AdminSearchParams; both AdminSearch and AdminSearchGrouped apply the SQL condition - Add has_description/has_image/has_website/has_location booleans to AdminMarketSummary, populated in ToAdminSummary from existing Market fields - Dropdown filter in the admin market list routes to the missing param - Coloured dot indicators per row (amber=image, orange=desc, red=website, purple=location) with title tooltips
This commit is contained in:
@@ -164,8 +164,9 @@ func ToDetail(m Market) MarketDetail {
|
||||
type AdminSearchParams struct {
|
||||
Status string `form:"status"`
|
||||
Query string `form:"q"`
|
||||
Sort string `form:"sort"` // name, city, date, created, status
|
||||
Order string `form:"order"` // asc, desc
|
||||
Missing string `form:"missing"` // description, image, website, location
|
||||
Sort string `form:"sort"` // name, city, date, created, status
|
||||
Order string `form:"order"` // asc, desc
|
||||
Page int `form:"page"`
|
||||
PerPage int `form:"per_page"`
|
||||
}
|
||||
@@ -187,19 +188,23 @@ func (p *AdminSearchParams) Offset() int {
|
||||
}
|
||||
|
||||
type AdminMarketSummary struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SeriesID uuid.UUID `json:"series_id"`
|
||||
Year int `json:"year"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
OrganizerName string `json:"organizer_name"`
|
||||
SubmitterName string `json:"submitter_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
SeriesID uuid.UUID `json:"series_id"`
|
||||
Year int `json:"year"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
OrganizerName string `json:"organizer_name"`
|
||||
SubmitterName string `json:"submitter_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
HasDescription bool `json:"has_description"`
|
||||
HasImage bool `json:"has_image"`
|
||||
HasWebsite bool `json:"has_website"`
|
||||
HasLocation bool `json:"has_location"`
|
||||
}
|
||||
|
||||
type AdminMarketDetail struct {
|
||||
@@ -259,19 +264,23 @@ type AdminDetailResponse struct {
|
||||
|
||||
func ToAdminSummary(m Market) AdminMarketSummary {
|
||||
return AdminMarketSummary{
|
||||
ID: m.ID,
|
||||
SeriesID: m.SeriesID,
|
||||
Year: m.Year,
|
||||
Slug: m.Slug,
|
||||
Name: m.Name,
|
||||
City: m.City,
|
||||
State: m.State,
|
||||
Status: m.Status,
|
||||
StartDate: m.StartDate.Format(time.DateOnly),
|
||||
EndDate: m.EndDate.Format(time.DateOnly),
|
||||
OrganizerName: m.OrganizerName,
|
||||
SubmitterName: m.SubmitterName,
|
||||
CreatedAt: m.CreatedAt.Format(time.RFC3339),
|
||||
ID: m.ID,
|
||||
SeriesID: m.SeriesID,
|
||||
Year: m.Year,
|
||||
Slug: m.Slug,
|
||||
Name: m.Name,
|
||||
City: m.City,
|
||||
State: m.State,
|
||||
Status: m.Status,
|
||||
StartDate: m.StartDate.Format(time.DateOnly),
|
||||
EndDate: m.EndDate.Format(time.DateOnly),
|
||||
OrganizerName: m.OrganizerName,
|
||||
SubmitterName: m.SubmitterName,
|
||||
CreatedAt: m.CreatedAt.Format(time.RFC3339),
|
||||
HasDescription: m.Description != "",
|
||||
HasImage: m.ImageURL != "",
|
||||
HasWebsite: m.Website != "",
|
||||
HasLocation: m.Latitude != nil,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -618,6 +618,17 @@ func (r *pgRepository) AdminSearch(ctx context.Context, params AdminSearchParams
|
||||
args = append(args, params.Query, "%"+params.Query+"%")
|
||||
}
|
||||
|
||||
switch params.Missing {
|
||||
case "description":
|
||||
conditions = append(conditions, "(e.description IS NULL OR e.description = '')")
|
||||
case "image":
|
||||
conditions = append(conditions, "(e.image_url IS NULL OR e.image_url = '')")
|
||||
case "website":
|
||||
conditions = append(conditions, "(e.website IS NULL OR e.website = '')")
|
||||
case "location":
|
||||
conditions = append(conditions, "e.location IS NULL")
|
||||
}
|
||||
|
||||
where := ""
|
||||
if len(conditions) > 0 {
|
||||
where = "WHERE " + strings.Join(conditions, " AND ")
|
||||
@@ -705,6 +716,17 @@ func (r *pgRepository) AdminSearchGrouped(ctx context.Context, params AdminSearc
|
||||
args = append(args, params.Query, "%"+params.Query+"%")
|
||||
}
|
||||
|
||||
switch params.Missing {
|
||||
case "description":
|
||||
conditions = append(conditions, "(e.description IS NULL OR e.description = '')")
|
||||
case "image":
|
||||
conditions = append(conditions, "(e.image_url IS NULL OR e.image_url = '')")
|
||||
case "website":
|
||||
conditions = append(conditions, "(e.website IS NULL OR e.website = '')")
|
||||
case "location":
|
||||
conditions = append(conditions, "e.location IS NULL")
|
||||
}
|
||||
|
||||
where := ""
|
||||
if len(conditions) > 0 {
|
||||
where = "WHERE " + strings.Join(conditions, " AND ")
|
||||
|
||||
@@ -132,6 +132,10 @@ export interface AdminMarketSummary {
|
||||
organizer_name: string;
|
||||
submitter_name: string;
|
||||
created_at: string;
|
||||
has_description: boolean;
|
||||
has_image: boolean;
|
||||
has_website: boolean;
|
||||
has_location: boolean;
|
||||
}
|
||||
|
||||
export interface AdminMarketDetail {
|
||||
|
||||
@@ -8,11 +8,12 @@ import type { PageServerLoad } from './$types.js';
|
||||
export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
const status = url.searchParams.get('status') ?? '';
|
||||
const q = url.searchParams.get('q') ?? '';
|
||||
const missing = url.searchParams.get('missing') ?? '';
|
||||
const page = url.searchParams.get('page') ?? '1';
|
||||
const sort = url.searchParams.get('sort') ?? '';
|
||||
const order = url.searchParams.get('order') ?? '';
|
||||
|
||||
const query = buildSearchQuery({ status, q, page, per_page: '20', sort, order });
|
||||
const query = buildSearchQuery({ status, q, missing, page, per_page: '20', sort, order });
|
||||
|
||||
try {
|
||||
const res = await serverFetch<AdminSeriesGroup[]>(`/admin/markets/grouped?${query}`, cookies);
|
||||
@@ -20,7 +21,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
return {
|
||||
groups: res.data,
|
||||
meta: res.meta as PaginationMeta,
|
||||
filters: { status, q, sort, order }
|
||||
filters: { status, q, missing, sort, order }
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof ApiClientError) {
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
let searchValue = $state(untrack(() => data.filters.q));
|
||||
let expandedSeries = $state(new Set<string>());
|
||||
|
||||
const missingOptions: { label: string; value: string }[] = [
|
||||
{ label: 'Alle', value: '' },
|
||||
{ label: 'Kein Bild', value: 'image' },
|
||||
{ label: 'Keine Beschreibung', value: 'description' },
|
||||
{ label: 'Keine Website', value: 'website' },
|
||||
{ label: 'Kein Standort', value: 'location' }
|
||||
];
|
||||
|
||||
const statusLabels: Record<EditionStatus, string> = {
|
||||
rumored: 'Ausstehend',
|
||||
confirmed: 'Bestätigt',
|
||||
@@ -39,6 +47,7 @@
|
||||
|
||||
const currentStatus = $derived(page.url.searchParams.get('status') ?? '');
|
||||
const currentQ = $derived(page.url.searchParams.get('q') ?? '');
|
||||
const currentMissing = $derived(page.url.searchParams.get('missing') ?? '');
|
||||
const currentSort = $derived(page.url.searchParams.get('sort') ?? '');
|
||||
const currentOrder = $derived(page.url.searchParams.get('order') ?? '');
|
||||
|
||||
@@ -52,12 +61,14 @@
|
||||
const params = new URLSearchParams();
|
||||
const status = overrides.status ?? currentStatus;
|
||||
const q = overrides.q ?? currentQ;
|
||||
const missing = overrides.missing ?? currentMissing;
|
||||
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 (missing) params.set('missing', missing);
|
||||
if (sort) params.set('sort', sort);
|
||||
if (order) params.set('order', order);
|
||||
if (pg !== '1') params.set('page', pg);
|
||||
@@ -127,6 +138,17 @@
|
||||
</a>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<select
|
||||
onchange={(e) => goto(buildUrl({ missing: (e.target as HTMLSelectElement).value }))}
|
||||
class="focus:border-primary-500 focus:ring-primary-500 rounded-lg border border-stone-300 bg-white px-3 py-1.5
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800 dark:text-stone-100"
|
||||
>
|
||||
{#each missingOptions as opt}
|
||||
<option value={opt.value} selected={currentMissing === opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Status filter tabs -->
|
||||
@@ -216,6 +238,24 @@
|
||||
({group.editions.length} Ausgaben)
|
||||
</span>
|
||||
{/if}
|
||||
<span class="ml-2 inline-flex gap-1">
|
||||
{#if !latest.has_image}<span
|
||||
title="Kein Bild"
|
||||
class="inline-block h-1.5 w-1.5 rounded-full bg-amber-400"
|
||||
></span>{/if}
|
||||
{#if !latest.has_description}<span
|
||||
title="Keine Beschreibung"
|
||||
class="inline-block h-1.5 w-1.5 rounded-full bg-orange-400"
|
||||
></span>{/if}
|
||||
{#if !latest.has_website}<span
|
||||
title="Keine Website"
|
||||
class="inline-block h-1.5 w-1.5 rounded-full bg-red-400"
|
||||
></span>{/if}
|
||||
{#if !latest.has_location}<span
|
||||
title="Kein Standort"
|
||||
class="inline-block h-1.5 w-1.5 rounded-full bg-purple-400"
|
||||
></span>{/if}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-stone-600 dark:text-stone-400">{latest.city}</td>
|
||||
<td class="px-4 py-3">
|
||||
|
||||
Reference in New Issue
Block a user