events werden nun erfolgreich gespeichert, branding wird nun erfolgreich gespeichert, emotionen können nun angelegt werden. Task Ansicht im Event admin verbessert, Buttons in FAB umgewandelt und vereinheitlicht. Teilen-Link Guest PWA schicker gemacht, SynGoogleFonts ausgebaut (mit Einzel-Family-Download).

This commit is contained in:
Codex Agent
2025-11-27 16:08:08 +01:00
parent bfa15cc48e
commit 96f8c5d63c
39 changed files with 1970 additions and 640 deletions

View File

@@ -433,10 +433,19 @@ export type TenantTask = {
is_completed: boolean;
event_type_id: number | null;
event_type?: TenantEventType | null;
emotion_id?: number | null;
emotion?: {
id: number;
name: string;
name_translations: Record<string, string>;
icon: string | null;
color: string | null;
} | null;
tenant_id: number | null;
collection_id: number | null;
source_task_id: number | null;
source_collection_id: number | null;
sort_order?: number | null;
assigned_events_count: number;
assigned_events?: TenantEvent[];
created_at: string | null;
@@ -452,6 +461,7 @@ export type TenantTaskCollection = {
description_translations: Record<string, string | null>;
tenant_id: number | null;
is_global: boolean;
is_mine?: boolean;
event_type?: {
id: number;
slug: string;
@@ -460,6 +470,8 @@ export type TenantTaskCollection = {
icon: string | null;
} | null;
tasks_count: number;
events_count?: number;
imports_count?: number;
position: number | null;
source_collection_id: number | null;
created_at: string | null;
@@ -951,6 +963,7 @@ function normalizeTask(task: JsonValue): TenantTask {
typeof task.event_type_id === 'number'
? Number(task.event_type_id)
: eventType?.id ?? null;
const emotionRaw = task.emotion ?? null;
return {
id: Number(task.id ?? 0),
@@ -969,6 +982,25 @@ function normalizeTask(task: JsonValue): TenantTask {
is_completed: Boolean(task.is_completed ?? false),
event_type_id: eventTypeId,
event_type: eventType,
sort_order:
typeof task.sort_order === 'number'
? Number(task.sort_order)
: task.pivot && typeof (task.pivot as { sort_order?: unknown }).sort_order === 'number'
? Number((task.pivot as { sort_order?: number }).sort_order)
: null,
emotion_id: typeof task.emotion_id === 'number' ? Number(task.emotion_id) : null,
emotion: emotionRaw
? {
id: Number(emotionRaw.id ?? 0),
name: pickTranslatedText(
normalizeTranslationMap(emotionRaw.name_translations ?? emotionRaw.name ?? {}),
String(emotionRaw.name ?? '')
),
name_translations: normalizeTranslationMap(emotionRaw.name_translations ?? emotionRaw.name ?? {}),
icon: emotionRaw.icon ?? null,
color: emotionRaw.color ?? null,
}
: null,
tenant_id: task.tenant_id ?? null,
collection_id: task.collection_id ?? null,
source_task_id: task.source_task_id ?? null,
@@ -1010,8 +1042,11 @@ function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection {
description_translations: descriptionTranslations ?? {},
tenant_id: raw.tenant_id ?? null,
is_global: !raw.tenant_id,
is_mine: Boolean(raw.tenant_id),
event_type: eventType,
tasks_count: Number(raw.tasks_count ?? raw.tasksCount ?? 0),
events_count: raw.events_count !== undefined ? Number(raw.events_count) : undefined,
imports_count: raw.imports_count !== undefined ? Number(raw.imports_count) : undefined,
position: raw.position !== undefined ? Number(raw.position) : null,
source_collection_id: raw.source_collection_id ?? null,
created_at: raw.created_at ?? null,
@@ -1020,7 +1055,7 @@ function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection {
}
function normalizeEmotion(raw: JsonValue): TenantEmotion {
const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {});
const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {}, undefined, true);
const descriptionTranslations = normalizeTranslationMap(
raw.description_translations ?? raw.description ?? {},
undefined,
@@ -1037,11 +1072,11 @@ function normalizeEmotion(raw: JsonValue): TenantEmotion {
name_translations: nameTranslations,
description: descriptionTranslations ? pickTranslatedText(descriptionTranslations, '') : null,
description_translations: descriptionTranslations ?? {},
icon: String(raw.icon ?? 'lucide-smile'),
icon: typeof raw.icon === 'string' ? raw.icon : 'lucide-smile',
color: String(raw.color ?? '#6366f1'),
sort_order: Number(raw.sort_order ?? 0),
is_active: Boolean(raw.is_active ?? true),
is_global: !raw.tenant_id,
is_global: raw.tenant_id === null || raw.tenant_id === undefined,
tenant_id: raw.tenant_id ?? null,
event_types: (eventTypes as JsonValue[]).map((eventType) => {
const translations = normalizeTranslationMap(eventType.name ?? {});
@@ -2086,6 +2121,8 @@ export async function getTaskCollections(params: {
search?: string;
event_type?: string;
scope?: 'global' | 'tenant';
top_picks?: boolean;
limit?: number;
} = {}): Promise<PaginatedResult<TenantTaskCollection>> {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set('page', String(params.page));
@@ -2093,6 +2130,8 @@ export async function getTaskCollections(params: {
if (params.search) searchParams.set('search', params.search);
if (params.event_type) searchParams.set('event_type', params.event_type);
if (params.scope) searchParams.set('scope', params.scope);
if (params.top_picks) searchParams.set('top_picks', '1');
if (params.limit) searchParams.set('limit', String(params.limit));
const queryString = searchParams.toString();
const response = await authorizedFetch(
@@ -2142,6 +2181,34 @@ export async function importTaskCollection(
throw new Error('Missing collection payload');
}
export async function detachTasksFromEvent(eventId: number, taskIds: number[]): Promise<void> {
const response = await authorizedFetch(`/api/v1/tenant/tasks/bulk-detach-event/${eventId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_ids: taskIds }),
});
if (!response.ok) {
const payload = await safeJson(response);
console.error('[API] Failed to detach tasks', response.status, payload);
throw new Error('Failed to detach tasks');
}
}
export async function reorderEventTasks(eventId: number, taskIds: number[]): Promise<void> {
const response = await authorizedFetch(`/api/v1/tenant/tasks/event/${eventId}/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_ids: taskIds }),
});
if (!response.ok) {
const payload = await safeJson(response);
console.error('[API] Failed to reorder tasks', response.status, payload);
throw new Error('Failed to reorder tasks');
}
}
export async function getEmotions(): Promise<TenantEmotion[]> {
const response = await authorizedFetch('/api/v1/tenant/emotions');
if (!response.ok) {
@@ -2176,6 +2243,17 @@ export async function updateEmotion(emotionId: number, payload: EmotionPayload):
return normalizeEmotion(json.data);
}
export async function deleteEmotion(emotionId: number): Promise<void> {
const response = await authorizedFetch(`/api/v1/tenant/emotions/${emotionId}`, {
method: 'DELETE',
});
if (!response.ok) {
const payload = await safeJson(response);
console.error('[API] Failed to delete emotion', response.status, payload);
throw new Error('Failed to delete emotion');
}
}
export async function getTasks(params: { page?: number; per_page?: number; search?: string } = {}): Promise<PaginatedResult<TenantTask>> {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set('page', String(params.page));
@@ -2240,8 +2318,12 @@ export async function assignTasksToEvent(eventId: number, taskIds: number[]): Pr
}
}
export async function getEventTasks(eventId: number, page = 1): Promise<PaginatedResult<TenantTask>> {
const response = await authorizedFetch(`/api/v1/tenant/tasks/event/${eventId}?page=${page}`);
export async function getEventTasks(
eventId: number,
page = 1,
perPage = 500,
): Promise<PaginatedResult<TenantTask>> {
const response = await authorizedFetch(`/api/v1/tenant/tasks/event/${eventId}?page=${page}&per_page=${perPage}`);
if (!response.ok) {
const payload = await safeJson(response);
console.error('[API] Failed to load event tasks', response.status, payload);

View File

@@ -178,7 +178,7 @@ export function CommandShelf() {
},
{
key: 'invites',
label: t('commandShelf.actions.invites.label', 'QR & Einladungen'),
label: t('commandShelf.actions.invites.label', 'QR-Codes'),
description: t('commandShelf.actions.invites.desc', 'Layouts exportieren oder Links kopieren.'),
icon: QrCode,
href: ADMIN_EVENT_INVITES_PATH(slug),
@@ -220,7 +220,7 @@ export function CommandShelf() {
},
{
key: 'invites',
label: t('commandShelf.metrics.invites', 'Einladungen'),
label: t('commandShelf.metrics.invites', 'QR-Codes'),
value: activeEvent.active_invites_count ?? activeEvent.total_invites_count,
hint: t('commandShelf.metrics.invitesHint', 'live'),
},
@@ -373,7 +373,7 @@ export function CommandShelf() {
{t('commandShelf.mobile.sheetTitle', 'Schnellaktionen')}
</SheetTitle>
<SheetDescription>
{t('commandShelf.mobile.sheetDescription', 'Moderation, Aufgaben und Einladungen an einem Ort.')}
{t('commandShelf.mobile.sheetDescription', 'Moderation, Aufgaben und QR-Codes an einem Ort.')}
</SheetDescription>
</SheetHeader>
<div className="flex flex-wrap gap-2 px-4 text-xs text-slate-500 dark:text-slate-300">

View File

@@ -36,7 +36,7 @@ function buildEventLinks(slug: string, t: ReturnType<typeof useTranslation>['t']
{ key: 'photobooth', label: t('eventMenu.photobooth', 'Photobooth'), href: ADMIN_EVENT_PHOTOBOOTH_PATH(slug) },
{ key: 'guests', label: t('eventMenu.guests', 'Team & Gäste'), href: ADMIN_EVENT_MEMBERS_PATH(slug) },
{ key: 'tasks', label: t('eventMenu.tasks', 'Aufgaben'), href: ADMIN_EVENT_TASKS_PATH(slug) },
{ key: 'invites', label: t('eventMenu.invites', 'Einladungen'), href: ADMIN_EVENT_INVITES_PATH(slug) },
{ key: 'invites', label: t('eventMenu.invites', 'QR-Codes'), href: ADMIN_EVENT_INVITES_PATH(slug) },
{ key: 'branding', label: t('eventMenu.branding', 'Branding & Fonts'), href: ADMIN_EVENT_BRANDING_PATH(slug) },
];
}

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
type ActionTone = 'primary' | 'secondary' | 'danger' | 'neutral';
export type FloatingAction = {
key: string;
label: string;
icon: LucideIcon;
onClick: () => void;
tone?: ActionTone;
disabled?: boolean;
loading?: boolean;
ariaLabel?: string;
};
export function FloatingActionBar({ actions, className }: { actions: FloatingAction[]; className?: string }): React.ReactElement | null {
if (!actions.length) {
return null;
}
const toneClasses: Record<ActionTone, string> = {
primary: 'bg-primary text-primary-foreground shadow-primary/25 hover:bg-primary/90 focus-visible:ring-primary/70 border border-primary/20',
secondary: 'bg-[var(--tenant-surface-strong)] text-[var(--tenant-foreground)] shadow-slate-300/60 hover:bg-[var(--tenant-surface)] focus-visible:ring-slate-200 border border-[var(--tenant-border-strong)]',
neutral: 'bg-white/90 text-slate-900 shadow-slate-200/80 hover:bg-white focus-visible:ring-slate-200 border border-slate-200 dark:bg-slate-800/80 dark:text-white dark:border-slate-700',
danger: 'bg-rose-500 text-white shadow-rose-300/50 hover:bg-rose-600 focus-visible:ring-rose-200 border border-rose-400/80',
};
return (
<div
className={cn(
'pointer-events-none fixed inset-x-4 bottom-[calc(env(safe-area-inset-bottom,0px)+72px)] z-50 sm:inset-auto sm:right-6 sm:bottom-6',
className
)}
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<div className="pointer-events-auto flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
{actions.map((action) => {
const Icon = action.icon;
const tone = action.tone ?? 'primary';
return (
<Button
key={action.key}
size="lg"
className={cn(
'group flex h-11 w-11 items-center justify-center gap-0 rounded-full p-0 text-sm font-semibold shadow-lg transition-all duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2',
toneClasses[tone]
)}
onClick={action.onClick}
disabled={action.disabled || action.loading}
aria-label={action.ariaLabel ?? action.label}
>
{action.loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Icon className="h-4 w-4" />
)}
<span className="hidden sm:inline">{action.label}</span>
</Button>
);
})}
</div>
</div>
);
}

View File

@@ -94,7 +94,7 @@ export function DashboardEventFocusCard({
},
{
key: 'invites',
label: t('stats.invites', 'Einladungen live'),
label: t('stats.invites', 'QR-Codes live'),
value: Number(event.active_invites_count ?? event.total_invites_count ?? 0).toLocaleString(),
},
];
@@ -110,7 +110,7 @@ export function DashboardEventFocusCard({
},
{
key: 'invites',
label: t('actions.invites', 'QR & Einladungen'),
label: t('actions.invites', 'QR-Codes'),
description: t('actions.invitesHint', 'Layouts exportieren oder Links kopieren.'),
icon: QrCode,
handler: onOpenInvites,

View File

@@ -7,7 +7,7 @@
"hero_subtitle": "Moderation, Uploads und Kommunikation laufen hier zusammen mobil wie auf dem Desktop.",
"features": [
"Überwache Uploads in Echtzeit und archiviere Highlights ohne Aufwand.",
"Erstelle Einladungen mit personalisierten QR-Codes und teile sie sofort.",
"Erstelle Zugangs-QR-Codes und teile sie sofort.",
"Steuere Aufgaben, Emotionen und Slideshows direkt vom Event aus."
],
"lead": "Du meldest dich über unser gesichertes Fotospiel-Login an und landest direkt im Event-Dashboard.",

View File

@@ -36,7 +36,7 @@
"photos": "Uploads",
"guests": "Team & Gäste",
"tasks": "Aufgaben",
"invites": "Einladungen",
"invites": "QR-Codes",
"toolkit": "Toolkit",
"recap": "Nachbereitung"
},
@@ -90,7 +90,7 @@
"mobile": {
"openActions": "Schnellaktionen öffnen",
"sheetTitle": "Schnellaktionen",
"sheetDescription": "Moderation, Aufgaben und Einladungen an einem Ort.",
"sheetDescription": "Moderation, Aufgaben und QR-Codes an einem Ort.",
"tip": "Tipp: Öffne hier deine wichtigsten Aktionen am Eventtag.",
"tipCta": "Verstanden"
},
@@ -103,8 +103,8 @@
"welcome": {
"eyebrow": "Event Admin",
"title": "Event-Branding, Aufgaben & Foto-Moderation in einer App.",
"subtitle": "Bereite dein Event vor, teile Einladungen, moderiere Uploads live und gib die Galerie danach frei.",
"badge": "Fotos, Aufgaben & Einladungen an einem Ort",
"subtitle": "Bereite dein Event vor, teile QR-Codes, moderiere Uploads live und gib die Galerie danach frei.",
"badge": "Fotos, Aufgaben & QR-Codes an einem Ort",
"loginPrompt": "Bereits Kunde? Login oben rechts.",
"cta": {
"login": "Login",
@@ -122,7 +122,7 @@
"subtitle": "Alles an einem Ort",
"branding": {
"title": "Branding & Layout",
"description": "Farben, Schriften, QR-Layouts und Einladungen in einem Fluss."
"description": "Farben, Schriften, QR-Layouts und QR-Zugänge in einem Fluss."
},
"tasks": {
"title": "Aufgaben & Emotion-Sets",
@@ -133,7 +133,7 @@
"description": "Uploads sofort prüfen, Highlights markieren und Galerie-Link teilen."
},
"invites": {
"title": "Einladungen & QR",
"title": "QR-Codes & Layouts",
"description": "Links und Druckvorlagen generieren mit Paketlimits im Blick."
}
},
@@ -146,7 +146,7 @@
"accent": "Setup"
},
"share": {
"title": "Teilen & Einladen",
"title": "Teilen & QR-Codes",
"description": "QRs/Links verteilen, Missionen auswählen, Team onboarden.",
"accent": "Share"
},
@@ -159,12 +159,12 @@
"plans": {
"title": "Pakete im Überblick",
"subtitle": "Wähle das passende Kontingent",
"hint": "Starter, Standard oder Reseller alles mit Moderation & Einladungen.",
"hint": "Starter, Standard oder Reseller alles mit Moderation & QR-Codes.",
"starter": {
"title": "Starter",
"badge": "Für ein Event",
"p1": "1 Event, Basis-Branding",
"p2": "Aufgaben & Einladungen inklusive",
"p2": "Aufgaben & QR-Codes inklusive",
"p3": "Moderation & Galerie-Link"
},
"standard": {
@@ -200,7 +200,7 @@
"preview": {
"title": "Was dich erwartet",
"items": [
"Moderation, Aufgaben und Einladungen als Schnellzugriff",
"Moderation, Aufgaben und QR-Codes als Schnellzugriff",
"Sticky Actions auf Mobile für den Eventtag",
"Paket-Status & Limits jederzeit sichtbar"
]

View File

@@ -43,7 +43,7 @@
"noDate": "Kein Datum",
"actions": {
"photos": "Uploads",
"invites": "QR & Einladungen",
"invites": "QR-Codes",
"tasks": "Aufgaben"
}
},
@@ -62,8 +62,8 @@
"hint": "Weise passende Aufgaben zu oder aktiviere den Foto-Modus ohne Aufgaben."
},
"qr": {
"title": "QR-Einladung erstellt",
"hint": "Erstelle eine QR-Einladung und lade die Drucklayouts herunter."
"title": "QR-Code erstellt",
"hint": "Erstelle einen QR-Code und lade die Drucklayouts herunter."
},
"package": {
"title": "Paket aktiv",
@@ -73,7 +73,7 @@
"actions": {
"createEvent": "Event erstellen",
"openTasks": "Tasks öffnen",
"openQr": "QR-Einladungen",
"openQr": "QR-Codes",
"openPackages": "Pakete ansehen"
}
},
@@ -168,7 +168,7 @@
},
"events": {
"question": "Wie arbeite ich mit Events?",
"answer": "Wähle dein aktives Event, passe Aufgaben an und teile Einladungen. Ausführliche Dokumentation folgt."
"answer": "Wähle dein aktives Event, passe Aufgaben an und teile QR-Codes. Ausführliche Dokumentation folgt."
},
"uploads": {
"question": "Wie moderiere ich Uploads?",

View File

@@ -232,7 +232,7 @@
"missingSlug": "Kein Event-Slug angegeben.",
"load": "Mitglieder konnten nicht geladen werden.",
"emailRequired": "Bitte gib eine E-Mail-Adresse ein.",
"invite": "Einladung konnte nicht verschickt werden.",
"invite": "QR-Code konnte nicht verschickt werden.",
"remove": "Mitglied konnte nicht entfernt werden."
},
"alerts": {
@@ -261,7 +261,7 @@
"namePlaceholder": "Name",
"roleLabel": "Rolle",
"rolePlaceholder": "Rolle wählen",
"submit": "Einladung senden"
"submit": "QR-Code senden"
},
"roles": {
"tenantAdmin": "Kunden-Admin",
@@ -282,7 +282,7 @@
"summary": "Übersicht",
"photos": "Uploads",
"tasks": "Aufgaben",
"invites": "Einladungen",
"invites": "QR-Codes",
"branding": "Branding",
"photobooth": "Photobooth",
"recap": "Nachbereitung"
@@ -372,7 +372,7 @@
},
"toolkit": {
"titleFallback": "Event-Day Toolkit",
"subtitle": "Behalte Uploads, Aufgaben und QR-Einladungen am Eventtag im Blick.",
"subtitle": "Behalte Uploads, Aufgaben und QR-Codes am Eventtag im Blick.",
"errors": {
"missingSlug": "Kein Event-Slug angegeben.",
"loadFailed": "Toolkit konnte nicht geladen werden.",
@@ -388,14 +388,14 @@
"errorTitle": "Fehler",
"attention": "Achtung",
"noTasks": "Noch keine Aufgaben zugewiesen aktiviere ein Paket oder lege Aufgaben fest.",
"noInvites": "Es gibt keine aktiven QR-Einladungen. Erstelle eine Einladung, um Gäste in die App zu holen.",
"noInvites": "Es gibt keine aktiven QR-Codes. Erstelle eine QR-Code, um Gäste in die App zu holen.",
"pendingPhotos": "Es warten Fotos auf Moderation. Prüfe die Uploads, bevor sie live gehen."
},
"metrics": {
"uploadsTotal": "Uploads gesamt",
"uploads24h": "Uploads (24h)",
"pendingPhotos": "Unmoderierte Fotos",
"activeInvites": "Aktive Einladungen",
"activeInvites": "Aktive QR-Codes",
"engagementMode": "Modus",
"modePhotoOnly": "Foto-Modus",
"modeTasks": "Aufgaben"
@@ -410,14 +410,14 @@
"statusPending": "Status: Prüfung ausstehend"
},
"invites": {
"title": "QR-Einladungen",
"title": "QR-Codes",
"subtitle": "Aktive Links und Layouts im Blick behalten.",
"activeCount": "{{count}} aktiv",
"totalCount": "{{count}} gesamt",
"empty": "Noch keine QR-Einladungen erstellt.",
"empty": "Noch keine QR-Codes erstellt.",
"statusActive": "Aktiv",
"statusInactive": "Inaktiv",
"manage": "Einladungen verwalten"
"manage": "QR-Codes verwalten"
},
"tasks": {
"title": "Aktive Aufgaben",
@@ -460,8 +460,8 @@
"collectionsCta": "Mission Packs anzeigen"
},
"customizer": {
"title": "QR-Einladung anpassen",
"description": "Passe Layout, Texte, Farben und Logo deiner Einladungskarten an.",
"title": "QR-Code anpassen",
"description": "Passe Layout, Texte, Farben und Logo deiner QR-Codeskarten an.",
"layout": "Layout",
"selectLayout": "Layout auswählen",
"headline": "Überschrift",
@@ -519,20 +519,20 @@
}
},
"invites": {
"cardTitle": "QR-Einladungen & Layouts",
"cardDescription": "Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.",
"subtitle": "Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.",
"cardTitle": "QR-Codes & Layouts",
"cardDescription": "Erzeuge QR-Codes, passe Layouts an und stelle druckfertige Vorlagen bereit.",
"subtitle": "Manage QR-Codes, Drucklayouts und Branding für deine Gäste.",
"tabs": {
"layout": "QR-Code-Layout anpassen",
"share": "Links & QR teilen",
"export": "Drucken & Export"
},
"summary": {
"active": "Aktive Einladungen",
"active": "Aktive QR-Codes",
"total": "Gesamt"
},
"workflow": {
"title": "Einladungs-Workflow",
"title": "QR-Codes-Workflow",
"description": "Durchlaufe Layout, Links und Export Schritt für Schritt.",
"badge": "Setup",
"steps": {
@@ -542,7 +542,7 @@
},
"share": {
"title": "Links & QR teilen",
"description": "Aktiviere Einladungen, kopiere QR-Codes und teile sie mit dem Team."
"description": "Aktiviere QR-Codes, kopiere QR-Codes und teile sie mit dem Team."
},
"export": {
"title": "Drucken & Export",
@@ -564,13 +564,13 @@
"editLayout": "Layout bearbeiten",
"editHint": "Farben & Texte direkt im Editor anpassen.",
"export": "Drucken/Export",
"create": "Weitere Einladung"
"create": "Weitere QR-Code"
},
"hint": "Teile den Link direkt im Team oder in Newslettern."
},
"actions": {
"refresh": "Aktualisieren",
"create": "Neue Einladung erstellen",
"create": "Neue QR-Code erstellen",
"backToList": "Zurück zur Übersicht",
"backToEvent": "Event öffnen",
"copy": "Link kopieren",
@@ -589,8 +589,8 @@
"qrAlt": "QR-Code Vorschau"
},
"empty": {
"title": "Noch keine Einladungen",
"copy": "Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten."
"title": "Noch keine QR-Codes",
"copy": "Erstelle eine QR-Code, um druckfertige QR-Layouts zu erhalten."
},
"errorTitle": "Aktion fehlgeschlagen",
"export": {
@@ -602,9 +602,9 @@
},
"previewHint": "Speichere deine Änderungen, um die Exportdateien neu zu erstellen.",
"noLayoutPreview": "Noch keine Vorschau verfügbar. Speichere das Layout zuerst.",
"selectPlaceholder": "Einladung auswählen",
"noInviteSelected": "Wähle zunächst eine Einladung aus, um Downloads zu starten.",
"noLayouts": "Für diese Einladung sind aktuell keine Layouts verfügbar.",
"selectPlaceholder": "QR-Code auswählen",
"noInviteSelected": "Wähle zunächst eine QR-Code aus, um Downloads zu starten.",
"noLayouts": "Für diese QR-Code sind aktuell keine Layouts verfügbar.",
"actions": {
"title": "Aktionen",
"description": "Starte deinen Testdruck oder lade die Layouts herunter.",
@@ -685,14 +685,14 @@
"title": "Live-Vorschau",
"subtitle": "So sieht dein Layout beim Export aus.",
"mobileOpen": "Vorschau anzeigen",
"mobileTitle": "Einladungsvorschau",
"mobileTitle": "QR-Codesvorschau",
"mobileHint": "Öffnet eine Vorschau in einem Overlay",
"readyForGuests": "Bereit für Gäste",
"instructions": "Dieser Link führt Gäste direkt zur Galerie und funktioniert zusammen mit dem QR-Code auf dem Ausdruck.",
"qrAlt": "QR-Code der Einladung"
"qrAlt": "QR-Code der QR-Code"
},
"placeholderTitle": "Kein Layout verfügbar",
"placeholderCopy": "Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.",
"placeholderCopy": "Erstelle eine QR-Code, damit du Texte, Farben und Drucklayouts bearbeiten kannst.",
"loadingTitle": "Layouts werden geladen",
"loadingDescription": "Bitte warte einen Moment, wir bereiten die Drucklayouts vor.",
"loadingError": "Layouts konnten nicht geladen werden.",
@@ -807,7 +807,7 @@
"edit": "Bearbeiten",
"members": "Team & Rollen",
"tasks": "Aufgaben verwalten",
"invites": "Einladungen & Layouts",
"invites": "QR-Codes & Layouts",
"photos": "Fotos moderieren",
"refresh": "Aktualisieren",
"buyMorePhotos": "Mehr Fotos freischalten",
@@ -815,11 +815,11 @@
"extendGallery": "Galerie verlängern"
},
"workspace": {
"detailSubtitle": "Behalte Status, Aufgaben und Einladungen deines Events im Blick.",
"toolkitSubtitle": "Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.",
"detailSubtitle": "Behalte Status, Aufgaben und QR-Codes deines Events im Blick.",
"toolkitSubtitle": "Moderation, Aufgaben und QR-Codes für deinen Eventtag bündeln.",
"hero": {
"badge": "Event",
"description": "Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.",
"description": "Konzentriere dich auf Aufgaben, Moderation und QR-Codes für dieses Event.",
"liveBadge": "Live?"
},
"sections": {
@@ -880,16 +880,16 @@
"uploadsTotal": "Uploads gesamt",
"uploads24h": "Uploads (24h)",
"pending": "Fotos in Moderation",
"activeInvites": "Aktive Einladungen"
"activeInvites": "Aktive QR-Codes"
},
"invites": {
"badge": "Einladungen",
"title": "QR-Einladungen",
"subtitle": "Behält aktive Einladungen und Layouts im Blick.",
"badge": "QR-Codes",
"title": "QR-Codes",
"subtitle": "Behält aktive QR-Codes und Layouts im Blick.",
"activeCount": "{{count}} aktiv",
"totalCount": "{{count}} gesamt",
"empty": "Noch keine Einladungen erstellt.",
"manage": "Layouts & Einladungen verwalten"
"empty": "Noch keine QR-Codes erstellt.",
"manage": "Layouts & QR-Codes verwalten"
},
"tasks": {
"badge": "Aufgaben",
@@ -1009,7 +1009,7 @@
"negative": "Brauch(t)e Unterstützung",
"best": {
"uploads": "Uploads & Geschwindigkeit",
"invites": "QR-Einladungen & Layouts",
"invites": "QR-Codes & Layouts",
"moderation": "Moderation & Export",
"experience": "Allgemeine App-Erfahrung"
},
@@ -1603,18 +1603,18 @@
},
"noEvents": {
"title": "Lass uns starten",
"description": "Erstelle dein erstes Event, um Uploads, Aufgaben und Einladungen freizuschalten.",
"description": "Erstelle dein erstes Event, um Uploads, Aufgaben und QR-Codes freizuschalten.",
"cta": "Event erstellen"
},
"draftEvent": {
"title": "Event noch als Entwurf",
"description": "Veröffentliche das Event, um Einladungen und Galerie freizugeben.",
"description": "Veröffentliche das Event, um QR-Codes und Galerie freizugeben.",
"cta": "Event öffnen"
},
"upcomingEvent": {
"title": "Event startet bald",
"description_today": "Heute findet ein Event statt checke Uploads und Tasks.",
"description_days": "Noch {{count}} Tage bereite Einladungen und Aufgaben vor.",
"description_days": "Noch {{count}} Tage bereite QR-Codes und Aufgaben vor.",
"cta": "Zum Event"
},
"pendingUploads": {

View File

@@ -24,6 +24,13 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
const eventDate = event.event_date ? new Date(event.event_date) : null;
const hasPassed = eventDate ? eventDate.getTime() <= Date.now() : false;
const hasBranding = (() => {
const settings = (event.settings ?? {}) as Record<string, unknown>;
const brandingAllowed = Boolean(settings.branding_allowed ?? true);
const packageAllowsBranding = brandingAllowed || settings.branding_allowed === undefined;
return packageAllowsBranding;
})();
const formatBadge = (value?: number | null): number | undefined => {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value;
@@ -31,7 +38,7 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
return undefined;
};
return [
const tabs = [
{
key: 'overview',
label: translate('eventMenu.summary', 'Übersicht'),
@@ -51,14 +58,9 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
},
{
key: 'invites',
label: translate('eventMenu.invites', 'Einladungen'),
label: translate('eventMenu.invites', 'QR-Codes'),
href: ADMIN_EVENT_INVITES_PATH(event.slug),
},
{
key: 'branding',
label: translate('eventMenu.branding', 'Branding'),
href: ADMIN_EVENT_BRANDING_PATH(event.slug),
},
{
key: 'photobooth',
label: translate('eventMenu.photobooth', 'Photobooth'),
@@ -72,4 +74,14 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
}]
: []),
];
if (hasBranding) {
tabs.splice(4, 0, {
key: 'branding',
label: translate('eventMenu.branding', 'Branding'),
href: ADMIN_EVENT_BRANDING_PATH(event.slug),
});
}
return tabs;
}

View File

@@ -15,8 +15,16 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { AdminLayout } from '../components/AdminLayout';
import { getEmotions, createEmotion, updateEmotion, TenantEmotion, EmotionPayload } from '../api';
import {
getEmotions,
createEmotion,
updateEmotion,
deleteEmotion,
TenantEmotion,
EmotionPayload,
} from '../api';
import { isAuthError } from '../auth/tokens';
import toast from 'react-hot-toast';
type EmotionFormState = {
name: string;
@@ -49,6 +57,7 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
const [error, setError] = React.useState<string | null>(null);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<TenantEmotion | null>(null);
const [saving, setSaving] = React.useState(false);
const [form, setForm] = React.useState<EmotionFormState>(INITIAL_FORM_STATE);
@@ -107,9 +116,11 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
const created = await createEmotion(payload);
setEmotions((prev) => [created, ...prev]);
setDialogOpen(false);
toast.success(t('emotions.toast.created', 'Emotion erstellt.'));
} catch (err) {
if (!isAuthError(err)) {
setError(t('emotions.errors.create'));
toast.error(t('emotions.toast.error', 'Emotion konnte nicht erstellt werden.'));
}
} finally {
setSaving(false);
@@ -120,13 +131,35 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
try {
const updated = await updateEmotion(emotion.id, { is_active: !emotion.is_active });
setEmotions((prev) => prev.map((item) => (item.id === updated.id ? updated : item)));
toast.success(
updated.is_active
? t('emotions.toast.activated', 'Emotion aktiviert.')
: t('emotions.toast.deactivated', 'Emotion deaktiviert.')
);
} catch (err) {
if (!isAuthError(err)) {
setError(t('emotions.errors.toggle'));
toast.error(t('emotions.toast.errorToggle', 'Emotion konnte nicht aktualisiert werden.'));
}
}
}
async function handleDeleteEmotion(emotion: TenantEmotion) {
setSaving(true);
try {
await deleteEmotion(emotion.id);
setEmotions((prev) => prev.filter((item) => item.id !== emotion.id));
toast.success(t('emotions.toast.deleted', 'Emotion gelöscht.'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('emotions.toast.deleteError', 'Emotion konnte nicht gelöscht werden.'));
}
} finally {
setSaving(false);
setDeleteTarget(null);
}
}
const locale = i18n.language.startsWith('en') ? enGB : de;
const title = embedded ? t('emotions.title') : t('emotions.title');
const subtitle = embedded
@@ -165,17 +198,18 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
) : emotions.length === 0 ? (
<EmptyEmotionsState onCreate={openCreateDialog} />
) : (
<div className="grid gap-4 sm:grid-cols-2">
{emotions.map((emotion) => (
<EmotionCard
key={emotion.id}
emotion={emotion}
onToggle={() => toggleEmotion(emotion)}
locale={locale}
/>
))}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
{emotions.map((emotion) => (
<EmotionCard
key={emotion.id}
emotion={emotion}
onToggle={() => toggleEmotion(emotion)}
onDelete={() => setDeleteTarget(emotion)}
locale={locale}
/>
))}
</div>
)}
</CardContent>
</Card>
@@ -187,6 +221,29 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
saving={saving}
onSubmit={handleCreate}
/>
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('emotions.delete.title', 'Emotion löschen?')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-slate-600">
{t('emotions.delete.confirm', { defaultValue: 'Soll "{{name}}" wirklich gelöscht werden?' , name: deleteTarget?.name ?? '' })}
</p>
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
{t('actions.cancel', 'Abbrechen')}
</Button>
<Button
variant="destructive"
onClick={() => deleteTarget && void handleDeleteEmotion(deleteTarget)}
disabled={saving}
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('actions.delete', 'Löschen')}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -203,10 +260,12 @@ export default function EmotionsPage() {
function EmotionCard({
emotion,
onToggle,
onDelete,
locale,
}: {
emotion: TenantEmotion;
onToggle: () => void;
onDelete: () => void;
locale: Locale;
}) {
const { t } = useTranslation('management');
@@ -252,7 +311,13 @@ function EmotionCard({
<Power className="mr-1 h-4 w-4" />
{emotion.is_active ? t('emotions.actions.disable') : t('emotions.actions.enable')}
</Button>
<div className="h-8 w-8 rounded-full border border-slate-200" style={{ backgroundColor: emotion.color ?? DEFAULT_COLOR }} />
{!emotion.is_global ? (
<Button variant="ghost" size="sm" className="text-rose-600 hover:bg-rose-50" onClick={onDelete}>
{t('actions.delete', 'Löschen')}
</Button>
) : (
<div className="h-8 w-8 rounded-full border border-slate-200" style={{ backgroundColor: emotion.color ?? DEFAULT_COLOR }} />
)}
</CardFooter>
</Card>
);

View File

@@ -2,11 +2,12 @@ import React, { useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Loader2, Moon, Sparkles, Sun } from 'lucide-react';
import { ArrowLeft, Moon, RotateCcw, Save, Sparkles, Sun, UploadCloud } from 'lucide-react';
import toast from 'react-hot-toast';
import { AdminLayout } from '../components/AdminLayout';
import { SectionCard, SectionHeader } from '../components/tenant';
import { FloatingActionBar, type FloatingAction } from '../components/FloatingActionBar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -14,6 +15,7 @@ import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { ADMIN_EVENT_VIEW_PATH } from '../constants';
import { getEvent, getTenantSettings, updateEvent, type TenantEvent } from '../api';
import { cn } from '@/lib/utils';
@@ -23,6 +25,20 @@ import { ensureFontLoaded, useTenantFonts } from '../lib/fonts';
const DEFAULT_FONT_VALUE = '__default';
const CUSTOM_FONT_VALUE = '__custom';
const MAX_LOGO_UPLOAD_BYTES = 1024 * 1024;
const EMOTICON_GRID: string[] = [
'✨', '🎉', '🎊', '🥳', '🎈', '🎁', '🎂', '🍾', '🥂', '🍻',
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇',
'🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚',
'😎', '🤩', '🤗', '🤝', '👍', '🙌', '👏', '👐', '🤲', '🙏',
'🤍', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤎', '🤍',
'⭐', '🌟', '💫', '🔥', '⚡', '🌈', '☀️', '🌅', '🌠', '🌌',
'🎵', '🎶', '🎤', '🎧', '🎸', '🥁', '🎺', '🎹', '🎻', '🪩',
'🍕', '🍔', '🌮', '🌯', '🍣', '🍱', '🍰', '🍪', '🍫', '🍩',
'☕', '🍵', '🥤', '🍹', '🍸', '🍷', '🍺', '🍻', '🥂', '🍾',
'📸', '🎥', '📹', '📱', '💡', '🛎️', '🪄', '🎯', '🏆', '🥇',
];
type BrandingForm = {
useDefault: boolean;
@@ -225,6 +241,43 @@ function resolvePreviewBranding(form: BrandingForm, tenantBranding: BrandingForm
return form;
}
function coerceEventName(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
const preferred = record.de ?? record.en ?? Object.values(record)[0];
if (typeof preferred === 'string') {
return preferred;
}
}
return '';
}
function coerceEventDate(value: unknown): string | null {
if (typeof value === 'string' && value.trim()) {
const raw = value.trim();
// If ISO with timezone, convert to local date (validation uses server local date)
const parsed = new Date(raw);
if (!Number.isNaN(parsed.valueOf())) {
const year = parsed.getFullYear();
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
if (raw.length >= 10) {
return raw.slice(0, 10);
}
return raw;
}
return null;
}
export default function EventBrandingPage(): React.ReactElement {
const { slug } = useParams<{ slug?: string }>();
const navigate = useNavigate();
@@ -232,6 +285,7 @@ export default function EventBrandingPage(): React.ReactElement {
const queryClient = useQueryClient();
const [form, setForm] = useState<BrandingForm>(DEFAULT_BRANDING_FORM);
const [previewTheme, setPreviewTheme] = useState<'light' | 'dark'>('light');
const [emoticonDialogOpen, setEmoticonDialogOpen] = useState(false);
const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts();
const title = t('branding.title', 'Branding & Fonts');
@@ -290,11 +344,32 @@ export default function EventBrandingPage(): React.ReactElement {
const mutation = useMutation({
mutationFn: async (payload: BrandingForm) => {
if (!slug) throw new Error('Missing event slug');
if (!slug) throw new Error('Missing event context');
// Fetch a fresh snapshot to ensure required fields are sent and settings are merged instead of overwritten.
const latest = await getEvent(slug);
const eventTypeId = latest.event_type_id ?? latest.event_type?.id;
const eventDate = coerceEventDate(latest.event_date ?? loadedEvent?.event_date);
const eventName = coerceEventName(latest.name ?? loadedEvent?.name);
if (!eventTypeId || !eventName || !eventDate) {
throw new Error('Missing required event fields');
}
const mergedSettings = {
...(latest.settings ?? {}),
branding: buildPayload(payload),
} as Record<string, unknown>;
const response = await updateEvent(slug, {
settings: {
branding: buildPayload(payload),
},
name: eventName,
slug: latest.slug,
event_type_id: eventTypeId,
event_date: eventDate,
status: latest.status,
is_active: latest.is_active,
package_id: latest.package?.id,
settings: mergedSettings,
});
return response;
},
@@ -304,10 +379,20 @@ export default function EventBrandingPage(): React.ReactElement {
},
onError: (error: unknown) => {
console.error('[branding] save failed', error);
if ((error as { meta?: { errors?: Record<string, string[]> } })?.meta?.errors) {
const errors = (error as { meta?: { errors?: Record<string, string[]> } }).meta?.errors ?? {};
const flat = Object.entries(errors)
.map(([key, messages]) => `${key}: ${Array.isArray(messages) ? messages.join(', ') : String(messages)}`)
.join('\n');
toast.error(flat || t('branding.saveError', 'Branding konnte nicht gespeichert werden.'));
return;
}
toast.error(t('branding.saveError', 'Branding konnte nicht gespeichert werden.'));
},
});
const { mutate, isPending } = mutation;
if (!slug) {
return (
<AdminLayout title={title} subtitle={subtitle} tabs={[]} currentTabKey="branding">
@@ -334,8 +419,61 @@ export default function EventBrandingPage(): React.ReactElement {
}
};
const handleFontPreview = (family: string) => {
const font = availableFonts.find((entry) => entry.family === family);
if (font) {
void ensureFontLoaded(font);
}
};
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (file.size > MAX_LOGO_UPLOAD_BYTES) {
toast.error(t('branding.logoTooLarge', 'Das Logo darf maximal 1 MB groß sein.'));
event.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = typeof reader.result === 'string' ? reader.result : '';
setForm((prev) => ({ ...prev, logo: { ...prev.logo, mode: 'upload', value: dataUrl } }));
};
reader.readAsDataURL(file);
};
const handleEmoticonSelect = (value: string) => {
setForm((prev) => ({ ...prev, logo: { ...prev.logo, mode: 'emoticon', value } }));
};
const previewBranding = resolvePreviewBranding(form, tenantBranding);
const fabActions = React.useMemo<FloatingAction[]>(() => {
if (!slug) return [];
return [
{
key: 'save',
label: isPending ? t('branding.saving', 'Speichern...') : t('branding.save', 'Branding speichern'),
icon: Save,
onClick: () => mutate(form),
loading: isPending,
disabled: isPending || eventLoading,
tone: 'primary',
},
{
key: 'reset',
label: t('branding.reset', 'Auf Standard zurücksetzen'),
icon: RotateCcw,
onClick: () => setForm({ ...(tenantBranding ?? DEFAULT_BRANDING_FORM), useDefault: true }),
disabled: isPending,
tone: 'secondary',
},
];
}, [slug, mutate, form, isPending, eventLoading, t, tenantBranding]);
return (
<AdminLayout
title={title}
@@ -349,7 +487,7 @@ export default function EventBrandingPage(): React.ReactElement {
</Button>
)}
>
<div className="space-y-4">
<div className="space-y-4 pb-28">
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={t('branding.sections.mode', 'Standard vs. Event-spezifisch')}
@@ -451,7 +589,17 @@ export default function EventBrandingPage(): React.ReactElement {
<SelectContent>
<SelectItem value={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
{availableFonts.map((font) => (
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem>
<SelectItem
key={font.family}
value={font.family}
onMouseEnter={() => handleFontPreview(font.family)}
onFocus={() => handleFontPreview(font.family)}
>
<span className="flex items-center justify-between gap-3">
<span className="truncate" style={{ fontFamily: font.family }}>{font.family}</span>
<span className="text-xs text-muted-foreground" style={{ fontFamily: font.family }}>AaBb</span>
</span>
</SelectItem>
))}
<SelectItem value={CUSTOM_FONT_VALUE}>{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem>
</SelectContent>
@@ -461,6 +609,7 @@ export default function EventBrandingPage(): React.ReactElement {
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, heading: e.target.value } }))}
disabled={form.useDefault}
placeholder="z. B. Playfair Display"
style={form.typography.heading ? { fontFamily: form.typography.heading } : undefined}
/>
</div>
<div className="space-y-2">
@@ -476,7 +625,17 @@ export default function EventBrandingPage(): React.ReactElement {
<SelectContent>
<SelectItem value={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
{availableFonts.map((font) => (
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem>
<SelectItem
key={font.family}
value={font.family}
onMouseEnter={() => handleFontPreview(font.family)}
onFocus={() => handleFontPreview(font.family)}
>
<span className="flex items-center justify-between gap-3">
<span className="truncate" style={{ fontFamily: font.family }}>{font.family}</span>
<span className="text-xs text-muted-foreground" style={{ fontFamily: font.family }}>AaBb</span>
</span>
</SelectItem>
))}
<SelectItem value={CUSTOM_FONT_VALUE}>{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem>
</SelectContent>
@@ -486,6 +645,7 @@ export default function EventBrandingPage(): React.ReactElement {
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, body: e.target.value } }))}
disabled={form.useDefault}
placeholder="z. B. Inter, sans-serif"
style={form.typography.body ? { fontFamily: form.typography.body } : undefined}
/>
</div>
<div className="space-y-2">
@@ -528,6 +688,72 @@ export default function EventBrandingPage(): React.ReactElement {
</SelectContent>
</Select>
</div>
{form.logo.mode === 'emoticon' && (
<div className="space-y-2">
<Label>{t('branding.logoEmoticonGrid', 'Schnellauswahl (Emoticons)')}</Label>
<Dialog open={emoticonDialogOpen} onOpenChange={setEmoticonDialogOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm" disabled={form.useDefault}>
{t('branding.openEmoticons', 'Emoticon-Gitter öffnen')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-xl bg-white/95 p-4 text-left dark:bg-slate-950">
<DialogHeader>
<DialogTitle className="text-base font-semibold">
{t('branding.logoEmoticonGrid', 'Schnellauswahl (Emoticons)')}
</DialogTitle>
</DialogHeader>
<div className="mt-3 grid grid-cols-8 gap-2 sm:grid-cols-10">
{EMOTICON_GRID.map((emoji) => {
const isActive = form.logo.value === emoji;
return (
<button
key={emoji}
type="button"
className={cn(
'flex h-12 w-full items-center justify-center rounded-lg text-3xl leading-none transition sm:h-12',
isActive
? 'bg-slate-900 text-white shadow-sm ring-2 ring-slate-900 dark:bg-white/90 dark:text-slate-900 dark:ring-white/90'
: 'bg-white text-slate-900 hover:bg-slate-100 dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700',
)}
onClick={() => {
handleEmoticonSelect(emoji);
setEmoticonDialogOpen(false);
}}
disabled={form.useDefault}
title={emoji}
>
<span aria-hidden>{emoji}</span>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
</div>
)}
{form.logo.mode === 'upload' && (
<div className="space-y-2">
<Label>{t('branding.logoUpload', 'Logo hochladen')}</Label>
<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="outline" size="sm" disabled={form.useDefault} onClick={() => document.getElementById('branding-logo-upload')?.click()}>
<UploadCloud className="mr-2 h-4 w-4" />
{t('branding.logoUploadButton', 'Datei auswählen')}
</Button>
<input
id="branding-logo-upload"
type="file"
accept="image/*"
className="hidden"
onChange={handleLogoUpload}
disabled={form.useDefault}
/>
<span className="text-xs text-slate-600 dark:text-slate-300">
{t('branding.logoUploadHint', 'Max. 1 MB, PNG/SVG/JPG. Aktueller Wert wird ersetzt.')}
</span>
</div>
</div>
)}
<div className="space-y-2">
<Label>{t('branding.logoPosition', 'Position')}</Label>
<Select
@@ -649,33 +875,7 @@ export default function EventBrandingPage(): React.ReactElement {
</SectionCard>
</div>
<div className="sticky bottom-0 z-40 mt-6 bg-gradient-to-t from-white via-white to-white/70 py-3 backdrop-blur dark:from-slate-900 dark:via-slate-900 dark:to-slate-900/60">
<div className="mx-auto flex max-w-5xl items-center justify-between gap-3 px-2 sm:px-0">
<div className="text-sm text-slate-600 dark:text-slate-200">
{form.useDefault
? t('branding.footer.default', 'Standard-Farben des Tenants aktiv.')
: t('branding.footer.custom', 'Event-spezifisches Branding aktiv.')}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
onClick={() => setForm({ ...(tenantBranding ?? DEFAULT_BRANDING_FORM), useDefault: true })}
>
{t('branding.reset', 'Auf Standard zurücksetzen')}
</Button>
<Button onClick={() => mutation.mutate(form)} disabled={mutation.isPending || eventLoading}>
{mutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('branding.saving', 'Speichern...')}
</>
) : (
t('branding.save', 'Branding speichern')
)}
</Button>
</div>
</div>
</div>
<FloatingActionBar actions={fabActions} />
</AdminLayout>
);
}
@@ -688,6 +888,14 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
color: textColor,
};
const logoVisual = useMemo(() => {
const looksLikeImage = branding.logo.mode === 'upload' && branding.logo.value && /^(data:|https?:)/i.test(branding.logo.value);
if (looksLikeImage) {
return <img src={branding.logo.value} alt="Logo" className="h-10 w-10 rounded-full object-cover" />;
}
return <span className="text-xl">{branding.logo.value || '✨'}</span>;
}, [branding.logo.mode, branding.logo.value]);
const buttonStyle: React.CSSProperties = branding.buttons.style === 'outline'
? {
border: `2px solid ${branding.buttons.primary || branding.palette.primary}`,
@@ -705,8 +913,8 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
<CardHeader className="p-0">
<div className="px-4 py-3" style={headerStyle}>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/90 text-xl">
{branding.logo.value || '✨'}
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-white/90 text-xl">
{logoVisual}
</div>
<div className="flex flex-col">
<CardTitle className="text-base font-semibold" style={{ fontFamily: branding.typography.heading || undefined }}>

View File

@@ -208,7 +208,7 @@ export default function EventDetailPage() {
const toolkitData = toolkit.data;
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const subtitle = t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.');
const subtitle = t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und QR-Codes deines Events im Blick.');
const currentTabKey = 'overview';
const eventTabs = React.useMemo(() => {
@@ -221,6 +221,11 @@ export default function EventDetailPage() {
return buildEventTabs(event, translateMenu, counts);
}, [event, stats?.uploads_total, toolkitData?.tasks?.summary?.total, t]);
const brandingAllowed = React.useMemo(() => {
const settings = (event?.settings ?? {}) as Record<string, unknown>;
return Boolean(settings.branding_allowed ?? true);
}, [event]);
const limitWarnings = React.useMemo(
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
[event?.limits, tCommon],
@@ -449,7 +454,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
</p>
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">{resolveName(event.name)}</h1>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.')}
{t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und QR-Code für dieses Event.')}
</p>
<div className="flex flex-wrap gap-2 text-xs">
<Badge variant="secondary" className="gap-1 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-100">
@@ -503,6 +508,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
/>
</div>
{brandingAllowed ? (
<BrandingMissionCard
event={event}
invites={toolkitData?.invites}
@@ -512,6 +518,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
/>
) : null}
{event.addons?.length ? (
<SectionCard>
<SectionHeader
@@ -758,9 +765,9 @@ function InviteSummary({ invites, navigateToInvites }: { invites: EventToolkit['
return (
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={t('events.invites.badge', 'Einladungen')}
title={t('events.invites.title', 'QR-Einladungen')}
description={t('events.invites.subtitle', 'Behält aktive Einladungen und Layouts im Blick.')}
eyebrow={t('events.invites.badge', 'QR-Codes')}
title={t('events.invites.title', 'QR-Codes & Layouts')}
description={t('events.invites.subtitle', 'Behält aktive QR-Codes und Layouts im Blick.')}
/>
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
<div className="flex gap-2 text-sm text-slate-900">
@@ -782,11 +789,11 @@ function InviteSummary({ invites, navigateToInvites }: { invites: EventToolkit['
))}
</ul>
) : (
<p className="text-xs text-slate-500">{t('events.invites.empty', 'Noch keine Einladungen erstellt.')}</p>
<p className="text-xs text-slate-500">{t('events.invites.empty', 'Noch keine QR-Codes erstellt.')}</p>
)}
<Button variant="outline" onClick={navigateToInvites} className="border-amber-200 text-amber-700 hover:bg-amber-50">
<QrCode className="mr-2 h-4 w-4" /> {t('events.invites.manage', 'Layouts & Einladungen verwalten')}
<QrCode className="mr-2 h-4 w-4" /> {t('events.invites.manage', 'QR-Codes & Layouts verwalten')}
</Button>
</div>
</SectionCard>
@@ -999,10 +1006,10 @@ function GalleryShareCard({
<SectionHeader
eyebrow={t('events.galleryShare.badge', 'Galerie')}
title={t('events.galleryShare.title', 'Galerie teilen')}
description={t('events.galleryShare.emptyDescription', 'Erstelle einen Einladungslink, um Fotos zu teilen.')}
description={t('events.galleryShare.emptyDescription', 'Erstelle einen QR-Codeslink, um Fotos zu teilen.')}
/>
<Button onClick={onManageInvites} className="w-full rounded-full bg-brand-rose text-white shadow-md shadow-rose-300/40">
{t('events.galleryShare.createInvite', 'Einladung erstellen')}
{t('events.galleryShare.createInvite', 'QR-Code erstellen')}
</Button>
</SectionCard>
);

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { AlertTriangle, ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
import toast from 'react-hot-toast';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -16,6 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { AdminLayout } from '../components/AdminLayout';
import { FloatingActionBar } from '../components/FloatingActionBar';
import {
createEvent,
getEvent,
@@ -67,6 +68,7 @@ export default function EventFormPage() {
const slugParam = params.slug ?? searchParams.get('slug') ?? undefined;
const isEdit = Boolean(slugParam);
const navigate = useNavigate();
const queryClient = useQueryClient();
const { t: tErrors } = useTranslation('common', { keyPrefix: 'errors' });
const { t: tForm } = useTranslation('management', { keyPrefix: 'eventForm' });
@@ -88,6 +90,7 @@ export default function EventFormPage() {
const [showUpgradeHint, setShowUpgradeHint] = React.useState(false);
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null);
const [eventPackageMeta, setEventPackageMeta] = React.useState<EventPackageMeta | null>(null);
const formRef = React.useRef<HTMLFormElement | null>(null);
const { data: packages, isLoading: packagesLoading } = useQuery({
queryKey: ['packages', 'endcustomer'],
@@ -143,7 +146,7 @@ export default function EventFormPage() {
queryKey: ['tenant', 'events', slugParam],
queryFn: () => getEvent(slugParam!),
enabled: Boolean(isEdit && slugParam),
staleTime: 60_000,
staleTime: 0,
});
React.useEffect(() => {
@@ -277,6 +280,16 @@ export default function EventFormPage() {
}
}
const handleSubmitClick = React.useCallback(() => {
if (formRef.current) {
if (typeof formRef.current.requestSubmit === 'function') {
formRef.current.requestSubmit();
} else {
formRef.current.submit();
}
}
}, []);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmedName = form.name.trim();
@@ -320,14 +333,20 @@ export default function EventFormPage() {
if (isEdit) {
const targetSlug = originalSlug ?? slugParam!;
const updated = await updateEvent(targetSlug, payload);
queryClient.invalidateQueries({ queryKey: ['tenant', 'events'] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'events', targetSlug] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'dashboard'] });
setOriginalSlug(updated.slug);
setShowUpgradeHint(false);
setError(null);
toast.success(tForm('actions.saved', 'Event gespeichert'));
navigate(ADMIN_EVENT_VIEW_PATH(updated.slug));
} else {
const { event: created } = await createEvent(payload);
queryClient.invalidateQueries({ queryKey: ['tenant', 'events'] });
setShowUpgradeHint(false);
setError(null);
toast.success(tForm('actions.saved', 'Event gespeichert'));
navigate(ADMIN_EVENT_VIEW_PATH(created.slug));
}
} catch (err) {
@@ -456,6 +475,26 @@ export default function EventFormPage() {
</Button>
);
const fabActions = [
{
key: 'save',
label: saving ? tForm('actions.saving', 'Speichert') : tForm('actions.save', 'Speichern'),
icon: Save,
onClick: handleSubmitClick,
loading: saving,
disabled: loading || !form.name.trim() || !form.slug.trim() || !form.eventTypeId,
tone: 'primary' as const,
},
{
key: 'cancel',
label: tForm('actions.cancel', 'Abbrechen'),
icon: ArrowLeft,
onClick: () => navigate(-1),
disabled: saving,
tone: 'secondary' as const,
},
];
return (
<AdminLayout
title={isEdit ? tForm('titles.edit', 'Event bearbeiten') : tForm('titles.create', 'Neues Event erstellen')}
@@ -500,7 +539,7 @@ export default function EventFormPage() {
</div>
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-fuchsia-100/60">
<Card className="border-0 bg-white/85 shadow-xl shadow-fuchsia-100/60 pb-28">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-pink-500" /> {tForm('sections.details.title', 'Eventdetails')}
@@ -513,7 +552,7 @@ export default function EventFormPage() {
{loading ? (
<FormSkeleton />
) : (
<form className="space-y-6" onSubmit={handleSubmit}>
<form className="space-y-6" onSubmit={handleSubmit} ref={formRef}>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="event-name">{tForm('fields.name.label', 'Eventname')}</Label>
@@ -585,26 +624,6 @@ export default function EventFormPage() {
</div>
</div>
<div className="flex flex-wrap gap-3">
<Button
type="submit"
disabled={saving || !form.name.trim() || !form.slug.trim() || !form.eventTypeId}
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" /> {tForm('actions.saving', 'Speichert')}
</>
) : (
<>
<Save className="h-4 w-4" /> {tForm('actions.save', 'Speichern')}
</>
)}
</Button>
<Button variant="ghost" type="button" onClick={() => navigate(-1)}>
{tForm('actions.cancel', 'Abbrechen')}
</Button>
</div>
<div className="sm:col-span-2 mt-6">
<Accordion type="single" collapsible defaultValue="package">
<AccordionItem value="package" className="border-0">
@@ -695,6 +714,7 @@ export default function EventFormPage() {
)}
</CardContent>
</Card>
<FloatingActionBar actions={fabActions} />
</AdminLayout>
);
}

View File

@@ -2,7 +2,7 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, ArrowLeft, CheckCircle2, Circle, Copy, Download, ExternalLink, Link2, Loader2, Mail, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart } from 'lucide-react';
import { AlertTriangle, ArrowLeft, CheckCircle2, Circle, Copy, Download, ExternalLink, Link2, Loader2, Mail, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart, Save, Plus } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -29,8 +29,6 @@ import {
import { isAuthError } from '../auth/tokens';
import {
ADMIN_EVENTS_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_PHOTOS_PATH,
} from '../constants';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { buildEventTabs } from '../lib/eventTabs';
@@ -57,6 +55,7 @@ import {
triggerDownloadFromDataUrl,
} from './components/invite-layout/export-utils';
import { useOnboardingProgress } from '../onboarding';
import { FloatingActionBar, type FloatingAction } from '../components/FloatingActionBar';
interface PageState {
event: TenantEvent | null;
@@ -219,7 +218,7 @@ export default function EventInvitesPage(): React.ReactElement {
setAddonsCatalog(catalog);
} catch (error) {
if (!isAuthError(error)) {
setState({ event: null, invites: [], loading: false, error: 'QR-Einladungen konnten nicht geladen werden.' });
setState({ event: null, invites: [], loading: false, error: 'QR-QR-Code konnten nicht geladen werden.' });
}
}
}, [slug]);
@@ -543,9 +542,11 @@ export default function EventInvitesPage(): React.ReactElement {
try {
await navigator.clipboard.writeText(invite.url);
setCopiedInviteId(invite.id);
toast.success(t('invites.actions.copied', 'Link kopiert'));
} catch {
// ignore clipboard failures
}
toast.success(t('invites.actions.created', 'QR-Code erstellt'));
markStep({
lastStep: 'invite',
serverStep: 'invite_created',
@@ -553,7 +554,8 @@ export default function EventInvitesPage(): React.ReactElement {
});
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erstellt werden.' }));
setState((prev) => ({ ...prev, error: 'QR-QR-Code konnte nicht erstellt werden.' }));
toast.error(t('invites.actions.createFailed', 'QR-Code konnte nicht erstellt werden.'));
}
} finally {
setCreatingInvite(false);
@@ -564,8 +566,10 @@ export default function EventInvitesPage(): React.ReactElement {
try {
await navigator.clipboard.writeText(invite.url);
setCopiedInviteId(invite.id);
toast.success(t('invites.actions.copied', 'Link kopiert'));
} catch (error) {
console.warn('[Invites] Clipboard copy failed', error);
toast.error(t('invites.actions.copyFailed', 'Link konnte nicht kopiert werden.'));
}
}
@@ -591,9 +595,11 @@ export default function EventInvitesPage(): React.ReactElement {
if (selectedInviteId === invite.id && !updated.is_active) {
setSelectedInviteId((prevId) => (prevId === updated.id ? null : prevId));
}
toast.success(t('invites.actions.revoked', 'QR-Code deaktiviert'));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht deaktiviert werden.' }));
setState((prev) => ({ ...prev, error: 'QR-QR-Code konnte nicht deaktiviert werden.' }));
toast.error(t('invites.actions.revokeFailed', 'QR-Code konnte nicht deaktiviert werden.'));
}
} finally {
setRevokingId(null);
@@ -616,6 +622,7 @@ export default function EventInvitesPage(): React.ReactElement {
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
setCustomizerDraft(null);
toast.success(t('invites.customizer.toastSaved', 'Layout gespeichert'));
markStep({
lastStep: 'branding',
serverStep: 'branding_configured',
@@ -627,6 +634,7 @@ export default function EventInvitesPage(): React.ReactElement {
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'Anpassung konnte nicht gespeichert werden.' }));
toast.error(t('invites.customizer.toastSaveFailed', 'Layout konnte nicht gespeichert werden.'));
}
} finally {
setCustomizerSaving(false);
@@ -699,9 +707,9 @@ export default function EventInvitesPage(): React.ReactElement {
const eventDateSegment = normalizeEventDateSegment(eventDate);
const filename = buildDownloadFilename(
['Einladungslayout', eventName, exportLayout.name ?? null, eventDateSegment],
['QR-Codeslayout', eventName, exportLayout.name ?? null, eventDateSegment],
normalizedFormat,
'einladungslayout',
'QR-Codeslayout',
);
const exportOptions = {
@@ -792,36 +800,8 @@ export default function EventInvitesPage(): React.ReactElement {
className="hover:text-foreground"
>
<ArrowLeft className="mr-1 h-3.5 w-3.5" />
{t('invites.actions.backToList', 'Zurück')}
{t('invites.actions.backToList', 'Zurück zur Übersicht')}
</Button>
{slug ? (
<>
<Button
size="sm"
variant="ghost"
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}
className="hover:text-foreground"
>
{t('invites.actions.backToEvent', 'Event öffnen')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug))}
className="hover:text-foreground"
>
{t('toolkit.actions.moderate', 'Fotos moderieren')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}
className="hover:text-foreground"
>
{t('toolkit.actions.backToEvent', 'Event-Day Toolkit')}
</Button>
</>
) : null}
</div>
);
@@ -860,6 +840,43 @@ export default function EventInvitesPage(): React.ReactElement {
[slug],
);
const fabActions = React.useMemo<FloatingAction[]>(() => {
const items: FloatingAction[] = [
{
key: 'create-invite',
label: creatingInvite ? t('invites.actions.creating', 'Erstellen...') : t('invites.actions.create', 'Neue QR-Code erstellen'),
icon: Plus,
onClick: () => { void handleCreateInvite(); },
loading: creatingInvite,
disabled: creatingInvite || state.event?.limits?.can_add_guests === false,
tone: 'primary',
},
{
key: 'refresh',
label: state.loading ? t('invites.actions.refreshing', 'Aktualisieren...') : t('invites.actions.refresh', 'Aktualisieren'),
icon: RefreshCw,
onClick: () => { void load(); },
loading: state.loading,
disabled: state.loading,
tone: 'secondary',
},
];
if (activeTab === 'layout' && selectedInvite && effectiveCustomization) {
items.unshift({
key: 'save-layout',
label: customizerSaving ? t('invites.customizer.actions.saving', 'Speichert...') : t('invites.customizer.actions.save', 'Layout speichern'),
icon: Save,
onClick: () => { void handleSaveCustomization(effectiveCustomization); },
loading: customizerSaving,
disabled: customizerSaving || customizerResetting,
tone: 'primary',
});
}
return items;
}, [activeTab, selectedInvite, effectiveCustomization, customizerSaving, customizerResetting, creatingInvite, state.event?.limits?.can_add_guests, state.loading, t, handleSaveCustomization, load]);
const limitScopeLabels = React.useMemo(
() => ({
photos: tLimits('photosTitle'),
@@ -882,70 +899,71 @@ export default function EventInvitesPage(): React.ReactElement {
return (
<AdminLayout
title={eventName}
subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')}
subtitle={t('invites.subtitle', 'Manage QR-Codes, Drucklayouts und Branding für deine Gäste.')}
actions={actions}
tabs={eventTabs}
currentTabKey="invites"
>
{limitWarnings.length > 0 && (
<div className="mb-6 space-y-2">
{limitWarnings.map((warning) => (
<Alert
key={warning.id}
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
<AlertTriangle className="h-4 w-4" />
{limitScopeLabels[warning.scope]}
</AlertTitle>
<AlertDescription className="text-sm">
{warning.message}
</AlertDescription>
</div>
{warning.scope === 'guests' ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Button
variant="outline"
size="sm"
onClick={() => { void handleAddonPurchase(); }}
disabled={addonBusy === 'guests'}
>
<ShoppingCart className="mr-2 h-4 w-4" />
{t('invites.actions.buyMoreGuests', 'Mehr Gäste freischalten')}
</Button>
<AddonsPicker
addons={addonsCatalog}
scope="guests"
onCheckout={(key) => { void handleAddonPurchase(key); }}
busy={addonBusy === 'guests'}
t={(key, fallback) => t(key, fallback)}
/>
<div className="pb-28">
{limitWarnings.length > 0 && (
<div className="mb-6 space-y-2">
{limitWarnings.map((warning) => (
<Alert
key={warning.id}
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
<AlertTriangle className="h-4 w-4" />
{limitScopeLabels[warning.scope]}
</AlertTitle>
<AlertDescription className="text-sm">
{warning.message}
</AlertDescription>
</div>
) : null}
</div>
</Alert>
))}
</div>
)}
{warning.scope === 'guests' ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Button
variant="outline"
size="sm"
onClick={() => { void handleAddonPurchase(); }}
disabled={addonBusy === 'guests'}
>
<ShoppingCart className="mr-2 h-4 w-4" />
{t('invites.actions.buyMoreGuests', 'Mehr Gäste freischalten')}
</Button>
<AddonsPicker
addons={addonsCatalog}
scope="guests"
onCheckout={(key) => { void handleAddonPurchase(key); }}
busy={addonBusy === 'guests'}
t={(key, fallback) => t(key, fallback)}
/>
</div>
) : null}
</div>
</Alert>
))}
</div>
)}
{state.event?.addons?.length ? (
<Card className="mb-6 border-0 bg-white/85 shadow-lg shadow-slate-100/50">
<CardHeader>
<CardTitle className="text-base font-semibold text-slate-900">{t('events.sections.addons.title', 'Add-ons & Upgrades')}</CardTitle>
<CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription>
</CardHeader>
<CardContent>
<AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key, fallback)} />
</CardContent>
</Card>
) : null}
{state.event?.addons?.length ? (
<Card className="mb-6 border-0 bg-white/85 shadow-lg shadow-slate-100/50">
<CardHeader>
<CardTitle className="text-base font-semibold text-slate-900">{t('events.sections.addons.title', 'Add-ons & Upgrades')}</CardTitle>
<CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription>
</CardHeader>
<CardContent>
<AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key, fallback)} />
</CardContent>
</Card>
) : null}
<InviteWorkflowSteps steps={workflowSteps} onSelectStep={(tab) => handleTabChange(tab)} />
<InviteWorkflowSteps steps={workflowSteps} onSelectStep={(tab) => handleTabChange(tab)} />
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
<TabsTrigger value="share" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
{t('invites.tabs.share', 'Links & QR teilen')}
@@ -969,7 +987,7 @@ export default function EventInvitesPage(): React.ReactElement {
<section className="rounded-3xl border border-[var(--tenant-border-strong)] bg-gradient-to-br from-[var(--tenant-surface-muted)] via-[var(--tenant-surface)] to-[var(--tenant-surface-strong)] p-6 shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
<div className="mb-6 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-foreground">{t('invites.customizer.heading', 'Einladungslayout anpassen')}</h2>
<h2 className="text-lg font-semibold text-foreground">{t('invites.customizer.heading', 'QR-Codeslayout anpassen')}</h2>
<p className="text-sm text-muted-foreground">
{t('invites.customizer.copy', 'Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.')}
</p>
@@ -1014,7 +1032,7 @@ export default function EventInvitesPage(): React.ReactElement {
disabled={state.invites.length === 0}
>
<SelectTrigger className="h-9 w-full min-w-[200px] sm:w-60">
<SelectValue placeholder={t('invites.export.selectPlaceholder', 'Einladung auswählen')} />
<SelectValue placeholder={t('invites.export.selectPlaceholder', 'QR-Code auswählen')} />
</SelectTrigger>
<SelectContent>
{state.invites.map((invite) => (
@@ -1216,7 +1234,7 @@ export default function EventInvitesPage(): React.ReactElement {
{selectedInvite.qr_code_data_url ? (
<img
src={selectedInvite.qr_code_data_url}
alt={t('invites.export.qr.alt', 'QR-Code der Einladung')}
alt={t('invites.export.qr.alt', 'QR-Code der QR-Code')}
className="h-40 w-40 rounded-2xl border border-[var(--tenant-border-strong)] bg-white p-3 shadow-md"
/>
) : (
@@ -1263,12 +1281,12 @@ export default function EventInvitesPage(): React.ReactElement {
</div>
) : (
<div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]">
{t('invites.export.noLayouts', 'Für diese Einladung sind aktuell keine Layouts verfügbar.')}
{t('invites.export.noLayouts', 'Für diese QR-Code sind aktuell keine Layouts verfügbar.')}
</div>
)
) : (
<div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]">
{t('invites.export.noInviteSelected', 'Wähle zunächst eine Einladung aus, um Downloads zu starten.')}
{t('invites.export.noInviteSelected', 'Wähle zunächst eine QR-Code aus, um Downloads zu starten.')}
</div>
)}
</CardContent>
@@ -1291,13 +1309,13 @@ export default function EventInvitesPage(): React.ReactElement {
<div className="space-y-2">
<CardTitle className="flex items-center gap-2 text-lg text-foreground">
<QrCode className="h-5 w-5 text-primary" />
{t('invites.cardTitle', 'QR-Einladungen & Layouts')}
{t('invites.cardTitle', 'QR-QR-Code & Layouts')}
</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{t('invites.cardDescription', 'Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.')}
{t('invites.cardDescription', 'Erzeuge QR-Code, passe Layouts an und stelle druckfertige Vorlagen bereit.')}
</CardDescription>
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] px-3 py-1 text-xs text-[var(--tenant-foreground-soft)]">
<span>{t('invites.summary.active', 'Aktive Einladungen')}: {inviteCountSummary.active}</span>
<span>{t('invites.summary.active', 'Aktive QR-Code')}: {inviteCountSummary.active}</span>
<span className="text-primary"></span>
<span>{t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total}</span>
</div>
@@ -1319,7 +1337,7 @@ export default function EventInvitesPage(): React.ReactElement {
className="bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90"
>
{creatingInvite ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Share2 className="mr-1 h-4 w-4" />}
{t('invites.actions.create', 'Neue Einladung erstellen')}
{t('invites.actions.create', 'Neue QR-Code erstellen')}
</Button>
{!state.loading && state.event?.limits?.can_add_guests === false && (
<p className="w-full text-xs text-amber-600">
@@ -1353,6 +1371,8 @@ export default function EventInvitesPage(): React.ReactElement {
</Card>
</TabsContent>
</Tabs>
</div>
<FloatingActionBar actions={fabActions} />
</AdminLayout>
);
}
@@ -1372,7 +1392,7 @@ function InviteWorkflowSteps({ steps, onSelectStep }: { steps: InviteWorkflowSte
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-base font-semibold text-foreground">
{t('invites.workflow.title', 'Einladungs-Workflow')}
{t('invites.workflow.title', 'QR-Codes-Workflow')}
</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{t('invites.workflow.description', 'Durchlaufe die Schritte in Reihenfolge Layout gestalten, Links teilen, Export starten.')}
@@ -1490,7 +1510,7 @@ function InviteShareSummaryCard({ invite, onCopy, onCreate, onOpenLayout, onOpen
</Button>
<Button variant="outline" onClick={onCreate} className="flex-1">
<Share2 className="mr-2 h-4 w-4" />
{t('invites.share.actions.create', 'Weitere Einladung')}
{t('invites.share.actions.create', 'Weitere QR-Code')}
</Button>
</div>
</div>
@@ -1562,7 +1582,7 @@ function InviteListCard({
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-foreground">{invite.label?.trim() || `Einladung #${invite.id}`}</span>
<span className="text-sm font-semibold text-foreground">{invite.label?.trim() || `QR-Code #${invite.id}`}</span>
<Badge variant="outline" className={statusBadgeClass(status)}>
{status}
</Badge>
@@ -1651,11 +1671,11 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {
const { t } = useTranslation('management');
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-10 text-center transition-colors">
<h3 className="text-base font-semibold text-foreground">{t('invites.empty.title', 'Noch keine Einladungen')}</h3>
<p className="text-sm text-muted-foreground">{t('invites.empty.copy', 'Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten.')}</p>
<h3 className="text-base font-semibold text-foreground">{t('invites.empty.title', 'Noch keine QR-Code')}</h3>
<p className="text-sm text-muted-foreground">{t('invites.empty.copy', 'Erstelle eine QR-Code, um druckfertige QR-Layouts zu erhalten.')}</p>
<Button onClick={onCreate} className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
<Share2 className="mr-1 h-4 w-4" />
{t('invites.actions.create', 'Neue Einladung erstellen')}
{t('invites.actions.create', 'Neue QR-Code erstellen')}
</Button>
</div>
);

View File

@@ -218,7 +218,7 @@ export default function EventRecapPage() {
return (
<AdminLayout
title={eventName}
subtitle={t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.')}
subtitle={t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und QR-Codes deines Events im Blick.')}
tabs={eventTabs}
currentTabKey="recap"
>

View File

@@ -9,16 +9,33 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragOverlay,
useDraggable,
useDroppable,
} from '@dnd-kit/core';
import {
CSS,
} from '@dnd-kit/utilities';
import { AdminLayout } from '../components/AdminLayout';
import {
assignTasksToEvent,
detachTasksFromEvent,
getEvent,
getEventTasks,
createTask,
getTasks,
getTaskCollections,
importTaskCollection,
@@ -29,11 +46,12 @@ import {
TenantTaskCollection,
TenantEmotion,
} from '../api';
import { EmotionsSection } from './EmotionsPage';
import { isAuthError } from '../auth/tokens';
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_BRANDING_PATH, buildEngagementTabPath } from '../constants';
import { extractBrandingPalette } from '../lib/branding';
import { filterEmotionsByEventType } from '../lib/emotions';
import { buildEventTabs } from '../lib/eventTabs';
import { Trash2 } from 'lucide-react';
export default function EventTasksPage() {
const { t } = useTranslation('management', { keyPrefix: 'eventTasks' });
@@ -46,7 +64,6 @@ export default function EventTasksPage() {
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]);
const [availableTasks, setAvailableTasks] = React.useState<TenantTask[]>([]);
const [selected, setSelected] = React.useState<number[]>([]);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [modeSaving, setModeSaving] = React.useState(false);
@@ -60,12 +77,33 @@ export default function EventTasksPage() {
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
const [emotionsLoading, setEmotionsLoading] = React.useState(false);
const [emotionsError, setEmotionsError] = React.useState<string | null>(null);
const [emotionFilter, setEmotionFilter] = React.useState<number[]>([]);
const [emotionsModalOpen, setEmotionsModalOpen] = React.useState(false);
const [newTaskTitle, setNewTaskTitle] = React.useState('');
const [newTaskDescription, setNewTaskDescription] = React.useState('');
const [newTaskEmotionId, setNewTaskEmotionId] = React.useState<number | null>(null);
const [newTaskDifficulty, setNewTaskDifficulty] = React.useState<TenantTask['difficulty'] | ''>('');
const [creatingTask, setCreatingTask] = React.useState(false);
const [draggingId, setDraggingId] = React.useState<number | null>(null);
const hydrateTasks = React.useCallback(async (targetEvent: TenantEvent) => {
try {
const refreshed = await getEventTasks(targetEvent.id, 1);
const [refreshed, libraryTasks] = await Promise.all([
getEventTasks(targetEvent.id, 1),
getTasks({ per_page: 200 }),
]);
const assignedIds = new Set(refreshed.data.map((task) => task.id));
setAssignedTasks(refreshed.data);
setAvailableTasks((prev) => prev.filter((task) => !assignedIds.has(task.id)));
const eventTypeId = targetEvent.event_type_id ?? null;
const filteredLibraryTasks = libraryTasks.data.filter((task) => {
if (assignedIds.has(task.id)) {
return false;
}
if (eventTypeId && task.event_type_id && task.event_type_id !== eventTypeId) {
return false;
}
return true;
});
setAvailableTasks(filteredLibraryTasks);
} catch (err) {
if (!isAuthError(err)) {
setError(t('errors.assign', 'Tasks konnten nicht geladen werden.'));
@@ -73,19 +111,31 @@ export default function EventTasksPage() {
}
}, [t]);
const statusLabels = React.useMemo(
() => ({
published: t('management.members.statuses.published', 'Veröffentlicht'),
draft: t('management.members.statuses.draft', 'Entwurf'),
}),
[t]
);
const palette = React.useMemo(() => extractBrandingPalette(event?.settings), [event?.settings]);
const relevantEmotions = React.useMemo(
() => filterEmotionsByEventType(emotions, event?.event_type_id ?? event?.event_type?.id ?? null),
[emotions, event?.event_type_id, event?.event_type?.id],
);
const relevantEmotions = React.useMemo(() => {
const filtered = filterEmotionsByEventType(emotions, event?.event_type_id ?? event?.event_type?.id ?? null);
return filtered.length > 0 ? filtered : emotions;
}, [emotions, event?.event_type_id, event?.event_type?.id]);
const emotionChips = React.useMemo(() => {
const map: Record<number, TenantEmotion> = {};
assignedTasks.forEach((task) => {
if (task.emotion) {
map[task.emotion.id] = {
...task.emotion,
name_translations: task.emotion.name_translations ?? {},
description: null,
description_translations: {},
sort_order: 0,
is_active: true,
tenant_id: null,
is_global: false,
event_types: [],
created_at: null,
updated_at: null,
} as TenantEmotion;
}
});
return Object.values(map);
}, [assignedTasks]);
React.useEffect(() => {
if (!slug) {
@@ -101,7 +151,7 @@ export default function EventTasksPage() {
const eventData = await getEvent(slug);
const [eventTasksResponse, libraryTasks] = await Promise.all([
getEventTasks(eventData.id, 1),
getTasks({ per_page: 50 }),
getTasks({ per_page: 200 }),
]);
if (cancelled) return;
setEvent(eventData);
@@ -135,36 +185,140 @@ export default function EventTasksPage() {
};
}, [slug, t]);
async function handleAssign() {
if (!event || selected.length === 0) return;
setSaving(true);
try {
await assignTasksToEvent(event.id, selected);
const refreshed = await getEventTasks(event.id, 1);
const assignedIds = new Set(refreshed.data.map((task) => task.id));
setAssignedTasks(refreshed.data);
setAvailableTasks((prev) => prev.filter((task) => !assignedIds.has(task.id)));
setSelected([]);
} catch (err) {
if (!isAuthError(err)) {
setError(t('errors.assign', 'Tasks konnten nicht zugewiesen werden.'));
}
} finally {
setSaving(false);
}
}
React.useEffect(() => {
setSelected((current) => current.filter((taskId) => availableTasks.some((task) => task.id === taskId)));
}, [availableTasks]);
const filteredAssignedTasks = React.useMemo(() => {
let list = assignedTasks;
if (emotionFilter.length > 0) {
const set = new Set(emotionFilter);
list = list.filter((task) => (task.emotion_id ? set.has(task.emotion_id) : false));
}
if (!taskSearch.trim()) {
return assignedTasks;
return list;
}
const term = taskSearch.toLowerCase();
return assignedTasks.filter((task) => `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(term));
}, [assignedTasks, taskSearch]);
return list.filter((task) => `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(term));
}, [assignedTasks, taskSearch, emotionFilter]);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
const handleAssignSingle = React.useCallback(
async (taskId: number) => {
if (!event) return;
const task = availableTasks.find((t) => t.id === taskId);
if (task) {
setAvailableTasks((prev) => prev.filter((t) => t.id !== taskId));
setAssignedTasks((prev) => [...prev, task]);
}
setSaving(true);
try {
await assignTasksToEvent(event.id, [taskId]);
toast.success(t('actions.assignedToast', 'Tasks wurden zugewiesen.'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('errors.assign', 'Tasks konnten nicht zugewiesen werden.'));
}
// revert optimistic change
if (task) {
setAssignedTasks((prev) => prev.filter((t) => t.id !== taskId));
setAvailableTasks((prev) => [...prev, task]);
}
} finally {
setSaving(false);
}
},
[availableTasks, event, hydrateTasks, t],
);
const handleDetachSingle = React.useCallback(
async (taskId: number) => {
if (!event) return;
const task = assignedTasks.find((t) => t.id === taskId);
if (task) {
setAssignedTasks((prev) => prev.filter((t) => t.id !== taskId));
setAvailableTasks((prev) => [...prev, task]);
}
setSaving(true);
try {
await detachTasksFromEvent(event.id, [taskId]);
toast.success(t('actions.removedToast', 'Tasks wurden entfernt.'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('errors.remove', 'Tasks konnten nicht entfernt werden.'));
}
// revert optimistic change
if (task) {
setAvailableTasks((prev) => prev.filter((t) => t.id !== taskId));
setAssignedTasks((prev) => [...prev, task]);
}
} finally {
setSaving(false);
}
},
[assignedTasks, event, hydrateTasks, t],
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || !active?.data?.current) {
setDraggingId(null);
return;
}
const originList = active.data.current.list as 'assigned' | 'library';
const overList = (over.data?.current?.list as 'assigned' | 'library' | undefined) ?? null;
const targetList =
overList ??
(over.id === 'assigned-dropzone'
? 'assigned'
: over.id === 'library-dropzone'
? 'library'
: null);
setDraggingId(null);
if (!targetList || targetList === originList) {
return;
}
const taskId = Number(active.id);
if (Number.isNaN(taskId)) {
return;
}
if (targetList === 'assigned') {
void handleAssignSingle(taskId);
} else {
void handleDetachSingle(taskId);
}
};
const handleCreateQuickTask = React.useCallback(async () => {
if (!event || !newTaskTitle.trim()) return;
setCreatingTask(true);
const emotion = emotions.find((e) => e.id === newTaskEmotionId) ?? null;
try {
const created = await createTask({
title: newTaskTitle.trim(),
description: newTaskDescription.trim() || null,
emotion_id: newTaskEmotionId ?? undefined,
event_type_id: event.event_type_id ?? undefined,
difficulty: newTaskDifficulty || undefined,
});
setAssignedTasks((prev) => [...prev, { ...created, emotion: emotion ?? created.emotion ?? null }]);
setAvailableTasks((prev) => prev.filter((task) => task.id !== created.id));
await assignTasksToEvent(event.id, [created.id]);
toast.success(t('actions.created', 'Aufgabe erstellt und zugewiesen.'));
setNewTaskTitle('');
setNewTaskDescription('');
setNewTaskEmotionId(null);
setNewTaskDifficulty('');
await hydrateTasks(event);
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('errors.create', 'Aufgabe konnte nicht erstellt werden.'));
}
} finally {
setCreatingTask(false);
}
}, [event, newTaskDescription, newTaskEmotionId, newTaskTitle, hydrateTasks, t]);
const eventTabs = React.useMemo(() => {
if (!event) {
@@ -183,7 +337,9 @@ export default function EventTasksPage() {
setCollectionsLoading(true);
setCollectionsError(null);
const eventTypeSlug = event?.event_type?.slug ?? null;
const query = eventTypeSlug ? { per_page: 6, event_type: eventTypeSlug } : { per_page: 6 };
const query = eventTypeSlug
? { top_picks: true, limit: 6, event_type: eventTypeSlug }
: { top_picks: true, limit: 6 };
getTaskCollections(query)
.then((result) => {
@@ -264,6 +420,31 @@ export default function EventTasksPage() {
return mode !== 'photo_only';
}, [event?.engagement_mode, event?.settings]);
const summaryBadges = !loading && event ? (
<div className="mb-4 flex flex-wrap gap-2">
<Badge className="flex items-center gap-2 rounded-full bg-slate-900 text-white">
<span className="text-xs uppercase tracking-wide text-white/80">
{t('summary.assigned', 'Zugeordnete Tasks')}
</span>
<span className="text-sm font-semibold">{assignedTasks.length}</span>
</Badge>
<Badge className="flex items-center gap-2 rounded-full bg-emerald-600/90 text-white">
<span className="text-xs uppercase tracking-wide text-white/80">
{t('summary.library', 'Bibliothek')}
</span>
<span className="text-sm font-semibold">{availableTasks.length}</span>
</Badge>
<Badge className="flex items-center gap-2 rounded-full bg-pink-500/90 text-white">
<span className="text-xs uppercase tracking-wide text-white/80">
{t('summary.mode', 'Aktiver Modus')}
</span>
<span className="text-sm font-semibold">
{tasksEnabled ? t('summary.tasksMode', 'Mission Cards') : t('summary.photoOnly', 'Nur Fotos')}
</span>
</Badge>
</div>
) : null;
async function handleModeChange(checked: boolean) {
if (!event || !slug) return;
@@ -316,6 +497,8 @@ export default function EventTasksPage() {
tabs={eventTabs}
currentTabKey="tasks"
>
{summaryBadges}
{error && (
<Alert variant="destructive">
<AlertTitle>{tDashboard('alerts.errorTitle', 'Fehler')}</AlertTitle>
@@ -340,12 +523,6 @@ export default function EventTasksPage() {
<TabsContent value="tasks" className="space-y-6">
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader>
<CardTitle className="text-xl text-slate-900">{renderName(event.name, t)}</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('eventStatus', {
status: statusLabels[event.status as keyof typeof statusLabels] ?? event.status,
})}
</CardDescription>
<div className="mt-4 flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white/70 p-4 text-sm text-slate-700">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
@@ -376,20 +553,6 @@ export default function EventTasksPage() {
{t('modes.updating', 'Einstellung wird gespeichert ...')}
</div>
) : null}
<div className="grid gap-3 text-xs sm:grid-cols-3">
<SummaryPill
label={t('summary.assigned', 'Zugeordnete Tasks')}
value={assignedTasks.length}
/>
<SummaryPill
label={t('summary.library', 'Bibliothek')}
value={availableTasks.length}
/>
<SummaryPill
label={t('summary.mode', 'Aktiver Modus')}
value={tasksEnabled ? t('summary.tasksMode', 'Mission Cards') : t('summary.photoOnly', 'Nur Fotos')}
/>
</div>
</div>
</CardHeader>
<CardContent className="pb-0">
@@ -413,8 +576,14 @@ export default function EventTasksPage() {
</AlertDescription>
</Alert>
</CardContent>
<CardContent className="grid gap-4 lg:grid-cols-2">
<section className="space-y-3">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={(event) => setDraggingId(Number(event.active.id))}
onDragEnd={handleDragEnd}
>
<CardContent className="grid gap-4 lg:grid-cols-2">
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<Sparkles className="h-4 w-4 text-pink-500" />
@@ -431,21 +600,65 @@ export default function EventTasksPage() {
</div>
</div>
{filteredAssignedTasks.length === 0 ? (
<EmptyState
message={
taskSearch.trim()
? t('sections.assigned.noResults', 'Keine Aufgaben zum Suchbegriff.')
: t('sections.assigned.empty', 'Noch keine Tasks zugewiesen.')
}
/>
) : (
<div className="space-y-2">
{filteredAssignedTasks.map((task) => (
<AssignedTaskRow key={task.id} task={task} />
))}
{emotionChips.length > 0 ? (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant={emotionFilter.length === 0 ? 'default' : 'outline'}
onClick={() => setEmotionFilter([])}
className="rounded-full"
>
{t('filters.allEmotions', 'Alle Emotionen')}
</Button>
{emotionChips.map((emotion) => {
const active = emotionFilter.includes(emotion.id);
return (
<Button
key={emotion.id}
size="sm"
variant={active ? 'default' : 'outline'}
onClick={() =>
setEmotionFilter((prev) =>
active ? prev.filter((id) => id !== emotion.id) : [...prev, emotion.id]
)
}
className="rounded-full"
style={
active
? { backgroundColor: emotion.color ?? '#e0f2fe', color: '#0f172a' }
: { borderColor: emotion.color ?? undefined, color: '#0f172a' }
}
>
{emotion.icon ? <span className="mr-1">{emotion.icon}</span> : null}
{emotion.name}
</Button>
);
})}
</div>
)}
) : null}
<DropZone id="assigned-dropzone">
{filteredAssignedTasks.length === 0 ? (
<EmptyState
message={
taskSearch.trim()
? t('sections.assigned.noResults', 'Keine Aufgaben zum Suchbegriff.')
: t('sections.assigned.empty', 'Noch keine Tasks zugewiesen.')
}
/>
) : (
<div className="space-y-2">
{filteredAssignedTasks.map((task) => (
<DraggableTaskCard
key={task.id}
task={task}
origin="assigned"
onRemove={() => void handleDetachSingle(task.id)}
/>
))}
</div>
)}
</DropZone>
</section>
<section className="space-y-3">
@@ -453,52 +666,102 @@ export default function EventTasksPage() {
<PlusCircle className="h-4 w-4 text-emerald-500" />
{t('sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
</h3>
<div className="space-y-2 rounded-2xl border border-emerald-100 bg-white/90 p-3 shadow-sm max-h-72 overflow-y-auto">
{availableTasks.length === 0 ? (
<EmptyState message={t('sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} />
) : (
availableTasks.map((task) => (
<label key={task.id} className="flex items-start gap-3 rounded-xl border border-transparent p-2 transition hover:border-emerald-200">
<Checkbox
checked={selected.includes(task.id)}
onCheckedChange={(checked) =>
setSelected((prev) =>
checked ? [...prev, task.id] : prev.filter((id) => id !== task.id)
)
}
<div className="rounded-2xl border border-emerald-100 bg-emerald-50/60 p-3 shadow-inner">
<p className="text-xs font-semibold text-emerald-700">{t('sections.library.quickCreate', 'Schnell neue Aufgabe anlegen')}</p>
<div className="mt-2 grid gap-2">
<Input
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
placeholder={t('sections.library.quickTitle', 'Titel der Aufgabe')}
disabled={!tasksEnabled || creatingTask}
/>
<Textarea
value={newTaskDescription}
onChange={(e) => setNewTaskDescription(e.target.value)}
placeholder={t('sections.library.quickDescription', 'Beschreibung (optional)')}
disabled={!tasksEnabled || creatingTask}
className="min-h-[70px]"
/>
<div className="flex items-center gap-2">
<label className="text-xs text-slate-700">{t('sections.library.quickEmotion', 'Emotion')}</label>
<select
className="h-9 rounded-lg border border-slate-200 bg-white px-2 text-sm"
value={newTaskEmotionId ?? ''}
onChange={(e) => setNewTaskEmotionId(e.target.value ? Number(e.target.value) : null)}
disabled={!tasksEnabled || creatingTask}
>
<option value="">{t('sections.library.quickEmotionNone', 'Keine')}</option>
{relevantEmotions.map((emotion) => (
<option key={emotion.id} value={emotion.id}>
{emotion.name}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-slate-700">{t('sections.library.quickDifficulty', 'Schwierigkeit')}</label>
<select
className="h-9 rounded-lg border border-slate-200 bg-white px-2 text-sm"
disabled={!tasksEnabled || creatingTask}
value={newTaskDifficulty}
onChange={(e) => setNewTaskDifficulty(e.target.value as TenantTask['difficulty'] | '')}
>
<option value="">{t('sections.library.quickDifficultyNone', 'Keine')}</option>
<option value="easy">{t('sections.library.difficulty.easy', 'Leicht')}</option>
<option value="medium">{t('sections.library.difficulty.medium', 'Mittel')}</option>
<option value="hard">{t('sections.library.difficulty.hard', 'Schwer')}</option>
</select>
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={() => void handleCreateQuickTask()}
disabled={!newTaskTitle.trim() || creatingTask || !tasksEnabled}
>
{creatingTask ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t('sections.library.quickCreateCta', 'Erstellen & zuweisen')
)}
</Button>
</div>
</div>
</div>
<DropZone id="library-dropzone">
<div className="space-y-2 max-h-72 overflow-y-auto">
{availableTasks.length === 0 ? (
<EmptyState message={t('sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} />
) : (
availableTasks.map((task) => (
<DraggableTaskCard
key={task.id}
task={task}
origin="library"
onAdd={() => void handleAssignSingle(task.id)}
disabled={!tasksEnabled}
/>
<div>
<p className="text-sm font-medium text-slate-900">{task.title}</p>
{task.description && <p className="text-xs text-slate-600">{task.description}</p>}
</div>
</label>
))
)}
</div>
<Button
onClick={() => void handleAssign()}
disabled={saving || selected.length === 0 || !tasksEnabled}
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('actions.assign', 'Ausgewählte Tasks zuweisen')}
</Button>
))
)}
</div>
</DropZone>
</section>
</CardContent>
</CardContent>
<DragOverlay>
{draggingId ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-white/90 px-4 py-3 shadow-sm opacity-80">
{filteredAssignedTasks.find((t) => t.id === draggingId)?.title ??
availableTasks.find((t) => t.id === draggingId)?.title}
</div>
) : null}
</DragOverlay>
</DndContext>
</Card>
<BrandingStoryPanel
event={event}
palette={palette}
<EmotionsCard
emotions={relevantEmotions}
emotionsLoading={emotionsLoading}
emotionsError={emotionsError}
collections={collections}
onOpenBranding={() => {
if (!slug) return;
navigate(ADMIN_EVENT_BRANDING_PATH(slug));
}}
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
onOpenEmotions={() => setEmotionsModalOpen(true)}
/>
</TabsContent>
<TabsContent value="packs">
@@ -514,6 +777,15 @@ export default function EventTasksPage() {
</Tabs>
</>
)}
<Dialog open={emotionsModalOpen} onOpenChange={setEmotionsModalOpen}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('tasks.emotions.manage', 'Emotionen verwalten')}</DialogTitle>
</DialogHeader>
<EmotionsSection embedded />
</DialogContent>
</Dialog>
</AdminLayout>
);
}
@@ -536,17 +808,107 @@ function TaskSkeleton() {
);
}
function AssignedTaskRow({ task }: { task: TenantTask }) {
const { t } = useTranslation('management');
function DropZone({ id, children }: { id: string; children: React.ReactNode }) {
const zone = id === 'assigned-dropzone' ? 'assigned' : 'library';
const { setNodeRef, isOver } = useDroppable({ id, data: { list: zone } });
return (
<div className="rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-slate-900">{task.title}</p>
<Badge variant="outline" className="border-pink-200 text-pink-600">
{mapPriority(task.priority, (key, fallback) => t(key, { defaultValue: fallback }))}
</Badge>
<div
ref={setNodeRef}
className={`rounded-2xl border border-dashed p-2 ${isOver ? 'border-pink-200 bg-pink-50/70' : 'border-slate-200/70'}`}
>
{children}
</div>
);
}
function DraggableTaskCard({
task,
origin,
onRemove,
onAdd,
disabled,
}: {
task: TenantTask;
origin: 'assigned' | 'library';
onRemove?: () => void;
onAdd?: () => void;
disabled?: boolean;
}) {
const { t } = useTranslation('management');
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useDraggable({
id: task.id,
data: { list: origin },
});
const style = {
transform: transform ? CSS.Translate.toString(transform) : undefined,
transition: transition || undefined,
opacity: isDragging ? 0.8 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3">
<button
className="mt-1 h-7 w-7 rounded-md border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-50 disabled:opacity-50"
{...listeners}
{...attributes}
disabled={disabled}
aria-label={t('library.dragHandle', 'Task verschieben')}
>
</button>
<div className="space-y-1">
<p className="text-sm font-medium text-slate-900">{task.title}</p>
{task.description ? <p className="text-xs text-slate-600">{task.description}</p> : null}
</div>
</div>
<div className="flex items-center gap-2">
{task.emotion ? (
<Badge
variant="outline"
className="border-transparent text-[11px]"
style={{
backgroundColor: `${task.emotion.color ?? '#eef2ff'}20`,
color: task.emotion.color ?? '#4338ca',
}}
>
{task.emotion.icon ? <span className="mr-1">{task.emotion.icon}</span> : null}
{task.emotion.name}
</Badge>
) : null}
<Badge variant="outline" className="border-pink-200 text-pink-600">
{mapPriority(task.priority, (key, fallback) => t(key, { defaultValue: fallback }))}
</Badge>
{origin === 'assigned' ? (
<Button
size="icon"
variant="ghost"
onClick={onRemove}
disabled={disabled}
aria-label={t('actions.remove', 'Vom Event entfernen')}
>
<Trash2 className="h-4 w-4 text-slate-500" />
</Button>
) : (
<Button
size="icon"
variant="ghost"
onClick={onAdd}
disabled={disabled}
aria-label={t('actions.assign', 'Zum Event hinzufügen')}
>
<PlusCircle className="h-4 w-4 text-emerald-600" />
</Button>
)}
</div>
</div>
{task.description && <p className="mt-1 text-xs text-slate-600">{task.description}</p>}
</div>
);
}
@@ -640,145 +1002,71 @@ function MissionPackGrid({
);
}
type BrandingStoryPanelProps = {
event: TenantEvent;
palette: ReturnType<typeof extractBrandingPalette>;
type EmotionsCardProps = {
emotions: TenantEmotion[];
emotionsLoading: boolean;
emotionsError: string | null;
collections: TenantTaskCollection[];
onOpenBranding: () => void;
onOpenEmotions: () => void;
onOpenCollections: () => void;
};
function BrandingStoryPanel({
event,
palette,
emotions,
emotionsLoading,
emotionsError,
collections,
onOpenBranding,
onOpenEmotions,
onOpenCollections,
}: BrandingStoryPanelProps) {
function EmotionsCard({ emotions, emotionsLoading, emotionsError, onOpenEmotions }: EmotionsCardProps) {
const { t } = useTranslation('management');
const fallbackColors = palette.colors.length ? palette.colors : ['#f472b6', '#fde68a', '#312e81'];
const spotlightEmotions = emotions.slice(0, 4);
const recommendedCollections = React.useMemo(() => collections.slice(0, 2), [collections]);
const spotlightEmotions = emotions.slice(0, 6);
return (
<Card className="border-0 bg-white/90 shadow-xl shadow-indigo-100/50">
<Card className="border border-rose-100 bg-rose-50/70 shadow-sm">
<CardHeader>
<CardTitle className="text-xl text-slate-900">
{t('tasks.story.title', 'Branding & Story')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('tasks.story.description', 'Verbinde Farben, Emotionen und Mission Packs für ein stimmiges Gäste-Erlebnis.')}
<CardTitle className="flex items-center gap-2 text-base text-rose-900">
<Sparkles className="h-5 w-5 text-rose-500" />
{t('tasks.story.emotionsTitle', 'Emotionen')}
</CardTitle>
<CardDescription className="text-sm text-rose-800">
{t('tasks.story.description', 'Aktiviere Emotionen, um Aufgaben zu kategorisieren.')}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-indigo-100 bg-indigo-50/80 p-4 text-sm text-indigo-900 shadow-inner shadow-indigo-100">
<p className="text-xs uppercase tracking-[0.3em]">
{t('events.branding.brandingTitle', 'Branding')}
</p>
<p className="mt-1 text-base font-semibold">{palette.font ?? t('events.branding.brandingFallback', 'Aktuelle Auswahl')}</p>
<p className="text-xs text-indigo-900/70">
{t('events.branding.brandingCopy', 'Passe Farben & Schriftarten im Layout-Editor an.')}
</p>
<div className="mt-3 flex gap-2">
{fallbackColors.slice(0, 4).map((color) => (
<span key={color} className="h-10 w-10 rounded-xl border border-white/70 shadow" style={{ backgroundColor: color }} />
))}
</div>
<Button size="sm" variant="secondary" className="mt-4 rounded-full bg-white/80 text-indigo-900 hover:bg-white" onClick={onOpenBranding}>
{t('events.branding.brandingCta', 'Branding anpassen')}
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<Badge variant="outline" className="border-rose-200 text-rose-700">
{t('tasks.story.emotionsCount', { defaultValue: '{{count}} aktiv', count: emotions.length })}
</Badge>
<Button
size="sm"
className="bg-rose-600 text-white hover:bg-rose-700"
onClick={onOpenEmotions}
>
{t('tasks.story.emotionsCta', 'Emotionen verwalten')}
</Button>
</div>
<div className="space-y-4 rounded-2xl border border-rose-100 bg-rose-50/70 p-4 text-sm text-rose-900 shadow-inner shadow-rose-100">
<div>
<div className="flex items-center justify-between">
<p className="text-xs uppercase tracking-[0.3em] text-rose-400">
{t('tasks.story.emotionsTitle', 'Emotionen')}
</p>
<Badge variant="outline" className="border-rose-200 text-rose-600">
{t('tasks.story.emotionsCount', { defaultValue: '{{count}} aktiviert', count: emotions.length })}
</Badge>
</div>
{emotionsLoading ? (
<div className="mt-3 h-10 animate-pulse rounded-xl bg-white/70" />
) : emotionsError ? (
<p className="mt-3 text-xs text-rose-900/70">{emotionsError}</p>
) : spotlightEmotions.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{spotlightEmotions.map((emotion) => (
<span
key={emotion.id}
className="flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold shadow-sm"
style={{
backgroundColor: `${emotion.color ?? '#fecdd3'}33`,
color: emotion.color ?? '#be123c',
}}
>
{emotion.icon ? <span>{emotion.icon}</span> : null}
{emotion.name}
</span>
))}
</div>
) : (
<p className="mt-3 text-xs text-rose-900/70">
{t('tasks.story.emotionsEmpty', 'Aktiviere Emotionen, um Aufgaben zu kategorisieren.')}
</p>
)}
<Button size="sm" variant="ghost" className="mt-3 h-8 px-0 text-rose-700 hover:bg-rose-100/80" onClick={onOpenEmotions}>
{t('tasks.story.emotionsCta', 'Emotionen verwalten')}
</Button>
{emotionsLoading ? (
<div className="h-10 animate-pulse rounded-xl bg-white/70" />
) : emotionsError ? (
<p className="text-xs text-rose-900/70">{emotionsError}</p>
) : spotlightEmotions.length ? (
<div className="flex flex-wrap gap-2">
{spotlightEmotions.map((emotion) => (
<span
key={emotion.id}
className="flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold shadow-sm"
style={{
backgroundColor: `${emotion.color ?? '#fecdd3'}33`,
color: emotion.color ?? '#be123c',
}}
>
{emotion.icon ? <span>{emotion.icon}</span> : null}
{emotion.name}
</span>
))}
</div>
<div className="rounded-xl border border-white/60 bg-white/80 p-3 text-sm text-slate-700">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">
{t('tasks.story.collectionsTitle', 'Mission Packs')}
</p>
{recommendedCollections.length ? (
<div className="mt-3 space-y-2">
{recommendedCollections.map((collection) => (
<div key={collection.id} className="flex items-center justify-between rounded-xl border border-slate-200 bg-white/90 px-3 py-2 text-xs">
<div>
<p className="text-sm font-semibold text-slate-900">{collection.name}</p>
{collection.event_type?.name ? (
<p className="text-[11px] text-slate-500">{collection.event_type.name}</p>
) : null}
</div>
<Badge variant="outline" className="border-slate-200 text-slate-600">
{t('tasks.story.collectionsCount', { defaultValue: '{{count}} Aufgaben', count: collection.tasks_count })}
</Badge>
</div>
))}
</div>
) : (
<p className="mt-3 text-xs text-slate-500">
{t('tasks.story.collectionsEmpty', 'Noch keine empfohlenen Mission Packs.')}
</p>
)}
<Button size="sm" variant="outline" className="mt-3 border-rose-200 text-rose-700 hover:bg-rose-50" onClick={onOpenCollections}>
{t('tasks.story.collectionsCta', 'Mission Packs anzeigen')}
</Button>
</div>
</div>
) : (
<p className="text-xs text-rose-900/70">
{t('tasks.story.emotionsEmpty', 'Aktiviere Emotionen, um Aufgaben zu kategorisieren.')}
</p>
)}
</CardContent>
</Card>
);
}
function SummaryPill({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-2xl border border-slate-200 bg-white/80 p-3 text-center">
<p className="text-xs uppercase tracking-wide text-slate-500">{label}</p>
<p className="mt-1 text-lg font-semibold text-slate-900">{value}</p>
</div>
);
}
function mapPriority(priority: TenantTask['priority'], translate: (key: string, defaultValue: string) => string): string {
switch (priority) {
case 'low':
@@ -791,10 +1079,3 @@ function mapPriority(priority: TenantTask['priority'], translate: (key: string,
return translate('management.eventTasks.priorities.medium', 'Mittel');
}
}
function renderName(name: TenantEvent['name'], translate: (key: string, defaultValue: string) => string): string {
if (typeof name === 'string') {
return name;
}
return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? translate('management.members.events.untitled', 'Unbenanntes Event');
}

View File

@@ -26,6 +26,7 @@ import {
} from '../api';
import { buildEngagementTabPath } from '../constants';
import { isAuthError } from '../auth/tokens';
import toast from 'react-hot-toast';
const DEFAULT_PAGE_SIZE = 12;
@@ -51,7 +52,6 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
const [scope, setScope] = React.useState<ScopeFilter>('all');
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [successMessage, setSuccessMessage] = React.useState<string | null>(null);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [selectedCollection, setSelectedCollection] = React.useState<TenantTaskCollection | null>(null);
@@ -86,6 +86,7 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
if (cancelled) return;
if (!isAuthError(err)) {
setError(t('collections.notifications.error'));
toast.error(t('collections.notifications.error'));
}
} finally {
if (!cancelled) {
@@ -101,14 +102,6 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
};
}, [page, search, scopeParam, reloadToken, t]);
React.useEffect(() => {
if (successMessage) {
const timeout = setTimeout(() => setSuccessMessage(null), 4000);
return () => clearTimeout(timeout);
}
return undefined;
}, [successMessage]);
async function ensureEventsLoaded() {
if (events.length > 0 || eventsLoading) {
return;
@@ -144,12 +137,13 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
setEventError(null);
try {
await importTaskCollection(selectedCollection.id, selectedEventSlug);
setSuccessMessage(t('collections.notifications.imported'));
toast.success(t('collections.notifications.imported'));
setDialogOpen(false);
setReloadToken((token) => token + 1);
} catch (err) {
if (!isAuthError(err)) {
setEventError(t('collections.notifications.error'));
toast.error(t('collections.notifications.error'));
}
} finally {
setImporting(false);
@@ -181,13 +175,6 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
</Alert>
)}
{successMessage && (
<Alert className="border-l-4 border-green-500 bg-green-50 text-sm text-green-900">
<AlertTitle>{t('collections.notifications.imported')}</AlertTitle>
<AlertDescription>{successMessage}</AlertDescription>
</Alert>
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>

View File

@@ -1,7 +1,9 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
AlignCenter,
AlignLeft,
AlignRight,
BadgeCheck,
ChevronDown,
Download,
@@ -611,6 +613,16 @@ export function InviteLayoutCustomizerPanel({
[availableFonts, updateElement]
);
const handleFontOptionPreview = React.useCallback(
(family: string) => {
const font = availableFonts.find((entry) => entry.family === family);
if (font) {
void ensureFontLoaded(font);
}
},
[availableFonts]
);
React.useEffect(() => {
if (!invite) {
setAvailableLayouts([]);
@@ -1315,14 +1327,17 @@ export function InviteLayoutCustomizerPanel({
value={element.align ?? 'left'}
onValueChange={(value) => value && updateElementAlign(element.id, value as 'left' | 'center' | 'right')}
>
<ToggleGroupItem value="left" className="px-3">
{t('invites.customizer.elements.alignLeft', 'Links')}
<ToggleGroupItem value="left" className="px-3" title={t('invites.customizer.elements.alignLeft', 'Links')} aria-label={t('invites.customizer.elements.alignLeft', 'Links')}>
<AlignLeft className="h-4 w-4" />
<span className="sr-only">{t('invites.customizer.elements.alignLeft', 'Links')}</span>
</ToggleGroupItem>
<ToggleGroupItem value="center" className="px-3">
{t('invites.customizer.elements.alignCenter', 'Zentriert')}
<ToggleGroupItem value="center" className="px-3" title={t('invites.customizer.elements.alignCenter', 'Zentriert')} aria-label={t('invites.customizer.elements.alignCenter', 'Zentriert')}>
<AlignCenter className="h-4 w-4" />
<span className="sr-only">{t('invites.customizer.elements.alignCenter', 'Zentriert')}</span>
</ToggleGroupItem>
<ToggleGroupItem value="right" className="px-3">
{t('invites.customizer.elements.alignRight', 'Rechts')}
<ToggleGroupItem value="right" className="px-3" title={t('invites.customizer.elements.alignRight', 'Rechts')} aria-label={t('invites.customizer.elements.alignRight', 'Rechts')}>
<AlignRight className="h-4 w-4" />
<span className="sr-only">{t('invites.customizer.elements.alignRight', 'Rechts')}</span>
</ToggleGroupItem>
</ToggleGroup>
</div>
@@ -1352,7 +1367,21 @@ export function InviteLayoutCustomizerPanel({
<SelectContent>
<SelectItem value={DEFAULT_FONT_VALUE}>{t('invites.customizer.elements.fontPlaceholder', 'Standard')}</SelectItem>
{availableFonts.map((font) => (
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem>
<SelectItem
key={font.family}
value={font.family}
onMouseEnter={() => handleFontOptionPreview(font.family)}
onFocus={() => handleFontOptionPreview(font.family)}
>
<span className="flex items-center justify-between gap-3">
<span className="truncate" style={{ fontFamily: font.family }}>
{font.family}
</span>
<span className="text-xs text-muted-foreground" style={{ fontFamily: font.family }}>
AaBb
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
@@ -1360,6 +1389,7 @@ export function InviteLayoutCustomizerPanel({
value={element.fontFamily ?? ''}
onChange={(event) => handleElementFontChange(element.id, event.target.value)}
placeholder="z. B. Playfair Display"
style={element.fontFamily ? { fontFamily: element.fontFamily } : undefined}
/>
</div>
</div>
@@ -1503,7 +1533,7 @@ export function InviteLayoutCustomizerPanel({
const normalizedFormat = format.toLowerCase();
const eventDateSegment = normalizeEventDateSegment(eventDate);
const filename = buildDownloadFilename(
['Einladungslayout', eventName, activeLayout?.name ?? null, eventDateSegment],
['QR-Layout', eventName, activeLayout?.name ?? null, eventDateSegment],
normalizedFormat,
'einladungslayout',
);