rearranged tenant admin layout, invite layouts now visible and manageable
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { authorizedFetch } from './auth/tokens';
|
||||
import i18n from './i18n';
|
||||
|
||||
type JsonValue = Record<string, any>;
|
||||
type JsonValue = Record<string, unknown>;
|
||||
|
||||
export type EventQrInviteLayout = {
|
||||
id: string;
|
||||
@@ -40,7 +40,11 @@ export type TenantEvent = {
|
||||
is_active?: boolean;
|
||||
description?: string | null;
|
||||
photo_count?: number;
|
||||
pending_photo_count?: number;
|
||||
like_count?: number;
|
||||
tasks_count?: number;
|
||||
active_invites_count?: number;
|
||||
total_invites_count?: number;
|
||||
engagement_mode?: 'tasks' | 'photo_only';
|
||||
settings?: Record<string, unknown> & { engagement_mode?: 'tasks' | 'photo_only' };
|
||||
package?: {
|
||||
@@ -442,7 +446,26 @@ function normalizeEvent(event: JsonValue): TenantEvent {
|
||||
is_active: typeof event.is_active === 'boolean' ? event.is_active : undefined,
|
||||
description: event.description ?? null,
|
||||
photo_count: event.photo_count !== undefined ? Number(event.photo_count ?? 0) : undefined,
|
||||
like_count: event.like_count !== undefined ? Number(event.like_count ?? 0) : undefined,
|
||||
pending_photo_count: event.pending_photo_count !== undefined ? Number(event.pending_photo_count ?? 0) : undefined,
|
||||
like_count:
|
||||
event.like_count !== undefined
|
||||
? Number(event.like_count ?? 0)
|
||||
: event.likes_sum !== undefined
|
||||
? Number(event.likes_sum ?? 0)
|
||||
: undefined,
|
||||
tasks_count: event.tasks_count !== undefined ? Number(event.tasks_count ?? 0) : undefined,
|
||||
active_invites_count:
|
||||
event.active_invites_count !== undefined
|
||||
? Number(event.active_invites_count ?? 0)
|
||||
: event.active_join_tokens_count !== undefined
|
||||
? Number(event.active_join_tokens_count ?? 0)
|
||||
: undefined,
|
||||
total_invites_count:
|
||||
event.total_invites_count !== undefined
|
||||
? Number(event.total_invites_count ?? 0)
|
||||
: event.total_join_tokens_count !== undefined
|
||||
? Number(event.total_join_tokens_count ?? 0)
|
||||
: undefined,
|
||||
engagement_mode: engagementMode,
|
||||
settings,
|
||||
package: event.package ?? null,
|
||||
@@ -638,9 +661,9 @@ function normalizeMember(member: JsonValue): EventMember {
|
||||
}
|
||||
|
||||
function normalizeQrInvite(raw: JsonValue): EventQrInvite {
|
||||
const rawLayouts = Array.isArray(raw.layouts) ? raw.layouts : [];
|
||||
const rawLayouts = Array.isArray(raw.layouts) ? (raw.layouts as JsonValue[]) : [];
|
||||
const layouts: EventQrInviteLayout[] = rawLayouts
|
||||
.map((layout: any) => {
|
||||
.map((layout: JsonValue) => {
|
||||
const formats = Array.isArray(layout.formats)
|
||||
? layout.formats.map((format: unknown) => String(format ?? '')).filter((format: string) => format.length > 0)
|
||||
: [];
|
||||
|
||||
@@ -6,19 +6,15 @@ import {
|
||||
ADMIN_HOME_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
ADMIN_TASKS_PATH,
|
||||
ADMIN_BILLING_PATH,
|
||||
ADMIN_TASK_COLLECTIONS_PATH,
|
||||
ADMIN_EMOTIONS_PATH,
|
||||
ADMIN_ENGAGEMENT_PATH,
|
||||
} from '../constants';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
|
||||
const navItems = [
|
||||
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', end: true },
|
||||
{ to: ADMIN_EVENTS_PATH, labelKey: 'navigation.events' },
|
||||
{ to: ADMIN_TASKS_PATH, labelKey: 'navigation.tasks' },
|
||||
{ to: ADMIN_TASK_COLLECTIONS_PATH, labelKey: 'navigation.collections' },
|
||||
{ to: ADMIN_EMOTIONS_PATH, labelKey: 'navigation.emotions' },
|
||||
{ to: ADMIN_ENGAGEMENT_PATH, labelKey: 'navigation.engagement' },
|
||||
{ to: ADMIN_BILLING_PATH, labelKey: 'navigation.billing' },
|
||||
{ to: ADMIN_SETTINGS_PATH, labelKey: 'navigation.settings' },
|
||||
];
|
||||
|
||||
@@ -8,9 +8,9 @@ export const ADMIN_LOGIN_PATH = adminPath('/login');
|
||||
export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
|
||||
export const ADMIN_EVENTS_PATH = adminPath('/events');
|
||||
export const ADMIN_SETTINGS_PATH = adminPath('/settings');
|
||||
export const ADMIN_TASKS_PATH = adminPath('/tasks');
|
||||
export const ADMIN_TASK_COLLECTIONS_PATH = adminPath('/task-collections');
|
||||
export const ADMIN_EMOTIONS_PATH = adminPath('/emotions');
|
||||
export const ADMIN_ENGAGEMENT_PATH = adminPath('/engagement');
|
||||
export const buildEngagementTabPath = (tab: 'tasks' | 'collections' | 'emotions'): string =>
|
||||
`${ADMIN_ENGAGEMENT_PATH}?tab=${encodeURIComponent(tab)}`;
|
||||
export const ADMIN_BILLING_PATH = adminPath('/billing');
|
||||
export const ADMIN_PHOTOS_PATH = adminPath('/photos');
|
||||
export const ADMIN_WELCOME_BASE_PATH = adminPath('/welcome');
|
||||
@@ -25,3 +25,4 @@ export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/eve
|
||||
export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/members`);
|
||||
export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`);
|
||||
export const ADMIN_EVENT_TOOLKIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/toolkit`);
|
||||
export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/invites`);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"tasks": "Aufgaben",
|
||||
"collections": "Aufgabenvorlagen",
|
||||
"emotions": "Emotionen",
|
||||
"engagement": "Aufgaben & Co.",
|
||||
"billing": "Abrechnung",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
|
||||
@@ -314,6 +314,83 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"summary": {
|
||||
"active": "Aktive Einladungen",
|
||||
"total": "Gesamt"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Aktualisieren",
|
||||
"create": "Neue Einladung erstellen",
|
||||
"backToList": "Zurück zur Übersicht",
|
||||
"backToEvent": "Event öffnen",
|
||||
"copy": "Link kopieren",
|
||||
"copied": "Kopiert!",
|
||||
"deactivate": "Deaktivieren"
|
||||
},
|
||||
"labels": {
|
||||
"usage": "Nutzung",
|
||||
"layout": "Layout",
|
||||
"layoutFallback": "Standard",
|
||||
"selected": "Aktuell ausgewählt",
|
||||
"tapToEdit": "Zum Anpassen auswählen",
|
||||
"noPrintSource": "Keine druckbare Version verfügbar."
|
||||
},
|
||||
"empty": {
|
||||
"title": "Noch keine Einladungen",
|
||||
"copy": "Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten."
|
||||
},
|
||||
"errorTitle": "Aktion fehlgeschlagen",
|
||||
"customizer": {
|
||||
"heading": "Layout anpassen",
|
||||
"copy": "Verleihe der Einladung euren Ton: Texte, Farben und Logo lassen sich live bearbeiten.",
|
||||
"actions": {
|
||||
"save": "Layout speichern",
|
||||
"reset": "Zurücksetzen",
|
||||
"print": "Drucken",
|
||||
"removeLogo": "Logo entfernen",
|
||||
"uploadLogo": "Logo hochladen (max. 1 MB)",
|
||||
"addInstruction": "Punkt hinzufügen"
|
||||
},
|
||||
"sections": {
|
||||
"layouts": "Layouts",
|
||||
"layoutsHint": "Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.",
|
||||
"text": "Texte",
|
||||
"instructions": "Schritt-für-Schritt",
|
||||
"instructionsHint": "Helft euren Gästen mit klaren Aufgaben. Maximal fünf Punkte.",
|
||||
"branding": "Branding"
|
||||
},
|
||||
"fields": {
|
||||
"headline": "Überschrift",
|
||||
"subtitle": "Unterzeile",
|
||||
"description": "Beschreibung",
|
||||
"badge": "Badge-Label",
|
||||
"cta": "Call-to-Action",
|
||||
"linkHeading": "Link-Überschrift",
|
||||
"linkLabel": "Link/Begleittext",
|
||||
"instructionsHeading": "Abschnittsüberschrift",
|
||||
"instructionPlaceholder": "Beschreibung des Schritts",
|
||||
"accentColor": "Akzentfarbe",
|
||||
"textColor": "Textfarbe",
|
||||
"backgroundColor": "Hintergrund",
|
||||
"badgeColor": "Badge",
|
||||
"logo": "Logo"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Live-Vorschau",
|
||||
"subtitle": "So sieht dein Layout beim Export aus."
|
||||
},
|
||||
"placeholderTitle": "Kein Layout verfügbar",
|
||||
"placeholderCopy": "Erstelle eine Einladung, 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.",
|
||||
"layoutFallback": "Layout"
|
||||
}
|
||||
},
|
||||
"collections": {
|
||||
"title": "Aufgabenvorlagen",
|
||||
"subtitle": "Durchstöbere kuratierte Vorlagen oder aktiviere sie für deine Events.",
|
||||
@@ -365,6 +442,9 @@
|
||||
"page": "Seite {{current}} von {{total}}"
|
||||
}
|
||||
},
|
||||
"engagement": {
|
||||
"subtitle": "Plane Aufgaben, Vorlagen und Emotionen gebündelt für deine Events."
|
||||
},
|
||||
"emotions": {
|
||||
"title": "Emotionen",
|
||||
"subtitle": "Verwalte Stimmungen und Icons, die deine Events begleiten.",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"tasks": "Tasks",
|
||||
"collections": "Collections",
|
||||
"emotions": "Emotions",
|
||||
"engagement": "Tasks & More",
|
||||
"billing": "Billing",
|
||||
"settings": "Settings"
|
||||
},
|
||||
|
||||
@@ -314,6 +314,83 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"invites": {
|
||||
"cardTitle": "QR invites & layouts",
|
||||
"cardDescription": "Create invite links, customise layouts, and prepare print-ready PDFs.",
|
||||
"subtitle": "Manage invite links, layouts, and branding for your guests.",
|
||||
"summary": {
|
||||
"active": "Active invites",
|
||||
"total": "Total"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
"create": "Create invite",
|
||||
"backToList": "Back to list",
|
||||
"backToEvent": "Open event",
|
||||
"copy": "Copy link",
|
||||
"copied": "Copied!",
|
||||
"deactivate": "Deactivate"
|
||||
},
|
||||
"labels": {
|
||||
"usage": "Usage",
|
||||
"layout": "Layout",
|
||||
"layoutFallback": "Default",
|
||||
"selected": "Currently selected",
|
||||
"tapToEdit": "Select to edit",
|
||||
"noPrintSource": "No printable version available."
|
||||
},
|
||||
"empty": {
|
||||
"title": "No invites yet",
|
||||
"copy": "Create an invite to generate ready-to-print QR layouts."
|
||||
},
|
||||
"errorTitle": "Action failed",
|
||||
"customizer": {
|
||||
"heading": "Customise layout",
|
||||
"copy": "Make the invite your own – adjust copy, colours, and logos in real time.",
|
||||
"actions": {
|
||||
"save": "Save layout",
|
||||
"reset": "Reset",
|
||||
"print": "Print",
|
||||
"removeLogo": "Remove logo",
|
||||
"uploadLogo": "Upload logo (max. 1 MB)",
|
||||
"addInstruction": "Add step"
|
||||
},
|
||||
"sections": {
|
||||
"layouts": "Layouts",
|
||||
"layoutsHint": "Pick a starting template. You can switch at any time.",
|
||||
"text": "Text",
|
||||
"instructions": "Step-by-step",
|
||||
"instructionsHint": "Guide guests with clear steps. Maximum of five.",
|
||||
"branding": "Branding"
|
||||
},
|
||||
"fields": {
|
||||
"headline": "Headline",
|
||||
"subtitle": "Subheading",
|
||||
"description": "Description",
|
||||
"badge": "Badge label",
|
||||
"cta": "Call-to-action",
|
||||
"linkHeading": "Link heading",
|
||||
"linkLabel": "Link/short URL",
|
||||
"instructionsHeading": "Section heading",
|
||||
"instructionPlaceholder": "Describe this step",
|
||||
"accentColor": "Accent colour",
|
||||
"textColor": "Text colour",
|
||||
"backgroundColor": "Background",
|
||||
"badgeColor": "Badge colour",
|
||||
"logo": "Logo"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Live preview",
|
||||
"subtitle": "See the export-ready version instantly."
|
||||
},
|
||||
"placeholderTitle": "No layout available",
|
||||
"placeholderCopy": "Create an invite first to customise copy, colours, and print layouts.",
|
||||
"loadingTitle": "Loading layouts",
|
||||
"loadingDescription": "One moment – we are preparing the available layouts.",
|
||||
"loadingError": "Layouts could not be loaded.",
|
||||
"layoutFallback": "Layout"
|
||||
}
|
||||
},
|
||||
"collections": {
|
||||
"title": "Task collections",
|
||||
"subtitle": "Browse curated task bundles or activate them for your events.",
|
||||
@@ -362,8 +439,11 @@
|
||||
"pagination": {
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {{current}} of {{total}}"
|
||||
}
|
||||
"page": "Page {{current}} of {{total}}"
|
||||
}
|
||||
},
|
||||
"engagement": {
|
||||
"subtitle": "Manage tasks, collections, and emotions from a single workspace."
|
||||
},
|
||||
"emotions": {
|
||||
"title": "Emotions",
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function AuthCallbackPage() {
|
||||
if (isAuthError(err) && err.code === 'token_exchange_failed') {
|
||||
setError('Anmeldung fehlgeschlagen. Bitte versuche es erneut.');
|
||||
} else if (isAuthError(err) && err.code === 'invalid_state') {
|
||||
setError('Ungueltiger Login-Vorgang. Bitte starte die Anmeldung erneut.');
|
||||
setError('Ungültiger Login-Vorgang. Bitte starte die Anmeldung erneut.');
|
||||
} else {
|
||||
setError('Unbekannter Fehler beim Login.');
|
||||
}
|
||||
|
||||
@@ -27,8 +27,6 @@ import {
|
||||
getDashboardSummary,
|
||||
getEvents,
|
||||
getTenantPackagesOverview,
|
||||
getEventTasks,
|
||||
getEventQrInvites,
|
||||
TenantEvent,
|
||||
TenantPackageSummary,
|
||||
} from '../api';
|
||||
@@ -39,11 +37,11 @@ import {
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_TASKS_PATH,
|
||||
ADMIN_BILLING_PATH,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
ADMIN_WELCOME_BASE_PATH,
|
||||
ADMIN_EVENT_CREATE_PATH,
|
||||
buildEngagementTabPath,
|
||||
} from '../constants';
|
||||
import { useOnboardingProgress } from '../onboarding';
|
||||
|
||||
@@ -122,12 +120,18 @@ export default function DashboardPage() {
|
||||
|
||||
setReadiness({
|
||||
hasEvent: events.length > 0,
|
||||
hasTasks: false,
|
||||
hasQrInvites: false,
|
||||
hasTasks: primaryEvent ? (Number(primaryEvent.tasks_count ?? 0) > 0) : false,
|
||||
hasQrInvites: primaryEvent
|
||||
? Number(
|
||||
primaryEvent.active_invites_count ??
|
||||
primaryEvent.active_join_tokens_count ??
|
||||
0
|
||||
) > 0
|
||||
: false,
|
||||
hasPackage: Boolean(packages.activePackage),
|
||||
primaryEventSlug: primaryEvent?.slug ?? null,
|
||||
primaryEventName,
|
||||
loading: Boolean(primaryEvent),
|
||||
loading: false,
|
||||
});
|
||||
|
||||
setState({
|
||||
@@ -138,28 +142,7 @@ export default function DashboardPage() {
|
||||
errorKey: null,
|
||||
});
|
||||
|
||||
if (primaryEvent) {
|
||||
try {
|
||||
const [eventTasks, qrInvites] = await Promise.all([
|
||||
getEventTasks(primaryEvent.id, 1),
|
||||
getEventQrInvites(primaryEvent.slug),
|
||||
]);
|
||||
|
||||
if (!cancelled) {
|
||||
setReadiness((prev) => ({
|
||||
...prev,
|
||||
hasTasks: (eventTasks.data ?? []).length > 0,
|
||||
hasQrInvites: qrInvites.length > 0,
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
} catch (readinessError) {
|
||||
if (!cancelled) {
|
||||
console.warn('Failed to load readiness checklist', readinessError);
|
||||
setReadiness((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
}
|
||||
} else if (!cancelled) {
|
||||
if (!primaryEvent && !cancelled) {
|
||||
setReadiness((prev) => ({
|
||||
...prev,
|
||||
hasTasks: false,
|
||||
@@ -338,7 +321,7 @@ export default function DashboardPage() {
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
label={translate('quickActions.organiseTasks.label')}
|
||||
description={translate('quickActions.organiseTasks.description')}
|
||||
onClick={() => navigate(ADMIN_TASKS_PATH)}
|
||||
onClick={() => navigate(buildEngagementTabPath('tasks'))}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Sparkles className="h-5 w-5" />}
|
||||
@@ -385,7 +368,7 @@ export default function DashboardPage() {
|
||||
onOpenTasks={() =>
|
||||
readiness.primaryEventSlug
|
||||
? navigate(ADMIN_EVENT_TASKS_PATH(readiness.primaryEventSlug))
|
||||
: navigate(ADMIN_TASKS_PATH)
|
||||
: navigate(buildEngagementTabPath('tasks'))
|
||||
}
|
||||
onOpenQr={() =>
|
||||
readiness.primaryEventSlug
|
||||
|
||||
@@ -3,34 +3,20 @@ import { useTranslation } from 'react-i18next';
|
||||
import { format } from 'date-fns';
|
||||
import { de, enGB } from 'date-fns/locale';
|
||||
import type { Locale } from 'date-fns';
|
||||
import { Palette, Plus, Power, Smile } from 'lucide-react';
|
||||
import { Loader2, Palette, Plus, Power, Smile } from 'lucide-react';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
getEmotions,
|
||||
createEmotion,
|
||||
updateEmotion,
|
||||
TenantEmotion,
|
||||
EmotionPayload,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
const DEFAULT_COLOR = '#6366f1';
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { getEmotions, createEmotion, updateEmotion, TenantEmotion, EmotionPayload } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
type EmotionFormState = {
|
||||
name: string;
|
||||
@@ -41,6 +27,7 @@ type EmotionFormState = {
|
||||
sort_order: number;
|
||||
};
|
||||
|
||||
const DEFAULT_COLOR = '#6366f1';
|
||||
const INITIAL_FORM_STATE: EmotionFormState = {
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -50,7 +37,11 @@ const INITIAL_FORM_STATE: EmotionFormState = {
|
||||
sort_order: 0,
|
||||
};
|
||||
|
||||
export default function EmotionsPage(): JSX.Element {
|
||||
export type EmotionsSectionProps = {
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export function EmotionsSection({ embedded = false }: EmotionsSectionProps): JSX.Element {
|
||||
const { t, i18n } = useTranslation('management');
|
||||
|
||||
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
|
||||
@@ -89,10 +80,10 @@ export default function EmotionsPage(): JSX.Element {
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
function openCreateDialog() {
|
||||
const openCreateDialog = React.useCallback(() => {
|
||||
setForm(INITIAL_FORM_STATE);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -137,21 +128,13 @@ export default function EmotionsPage(): JSX.Element {
|
||||
}
|
||||
|
||||
const locale = i18n.language.startsWith('en') ? enGB : de;
|
||||
const title = embedded ? t('emotions.title') : t('emotions.title');
|
||||
const subtitle = embedded
|
||||
? t('emotions.subtitle')
|
||||
: t('emotions.subtitle');
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={t('emotions.title') ?? 'Emotions'}
|
||||
subtitle={t('emotions.subtitle') ?? ''}
|
||||
actions={
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
onClick={openCreateDialog}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('emotions.actions.create')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('emotions.errors.genericTitle')}</AlertTitle>
|
||||
@@ -160,15 +143,27 @@ export default function EmotionsPage(): JSX.Element {
|
||||
)}
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl text-slate-900">{t('emotions.title')}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">{t('emotions.subtitle')}</CardDescription>
|
||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Palette className="h-5 w-5 text-pink-500" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">{subtitle}</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
onClick={openCreateDialog}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('emotions.actions.create')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{loading ? (
|
||||
<EmotionSkeleton />
|
||||
) : emotions.length === 0 ? (
|
||||
<EmptyEmotionsState />
|
||||
<EmptyEmotionsState onCreate={openCreateDialog} />
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{emotions.map((emotion) => (
|
||||
@@ -177,7 +172,6 @@ export default function EmotionsPage(): JSX.Element {
|
||||
emotion={emotion}
|
||||
onToggle={() => toggleEmotion(emotion)}
|
||||
locale={locale}
|
||||
canToggle={!emotion.is_global}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -193,6 +187,15 @@ export default function EmotionsPage(): JSX.Element {
|
||||
saving={saving}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EmotionsPage(): JSX.Element {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<AdminLayout title={t('emotions.title')} subtitle={t('emotions.subtitle')}>
|
||||
<EmotionsSection />
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -201,95 +204,60 @@ function EmotionCard({
|
||||
emotion,
|
||||
onToggle,
|
||||
locale,
|
||||
canToggle,
|
||||
}: {
|
||||
emotion: TenantEmotion;
|
||||
onToggle: () => void;
|
||||
locale: Locale;
|
||||
canToggle: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const updatedLabel = emotion.updated_at
|
||||
? format(new Date(emotion.updated_at), 'PP', { locale })
|
||||
: null;
|
||||
|
||||
const updated = emotion.updated_at ? format(new Date(emotion.updated_at), 'Pp', { locale }) : null;
|
||||
return (
|
||||
<Card className="border border-slate-100 bg-white/90 shadow-sm">
|
||||
<CardHeader className="space-y-3">
|
||||
<Card className="border border-slate-200/70 bg-white/85 shadow-md shadow-pink-100/20">
|
||||
<CardHeader className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={emotion.is_global ? 'border-indigo-200 text-indigo-600' : 'border-pink-200 text-pink-600'}
|
||||
<div
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: `${emotion.color}20`, color: emotion.color ?? DEFAULT_COLOR }}
|
||||
>
|
||||
{emotion.is_global ? t('emotions.scope.global') : t('emotions.scope.tenant')}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="bg-slate-100 text-slate-700">
|
||||
{emotion.event_types.map((type) => type.name).join(', ') || t('emotions.labels.noEventType')}
|
||||
</Badge>
|
||||
<Smile className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base text-slate-900">{emotion.name}</CardTitle>
|
||||
{emotion.description ? (
|
||||
<CardDescription className="text-xs text-slate-500">{emotion.description}</CardDescription>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<Smile className="h-4 w-4" />
|
||||
{emotion.name}
|
||||
</CardTitle>
|
||||
{emotion.description && (
|
||||
<CardDescription className="text-sm text-slate-600">{emotion.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between text-xs text-slate-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
<span>{emotion.color}</span>
|
||||
</div>
|
||||
{updatedLabel && <span>{t('emotions.labels.updated', { date: updatedLabel })}</span>}
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between border-t border-slate-100 bg-slate-50/60 px-4 py-3">
|
||||
<span className="text-xs text-slate-500">
|
||||
<Badge variant={emotion.is_active ? 'default' : 'secondary'}>
|
||||
{emotion.is_active ? t('emotions.status.active') : t('emotions.status.inactive')}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
disabled={!canToggle}
|
||||
className={!canToggle ? 'pointer-events-none opacity-60' : ''}
|
||||
>
|
||||
<Power className="mr-2 h-4 w-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-slate-600">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">#{emotion.icon}</Badge>
|
||||
{emotion.event_types?.length ? (
|
||||
emotion.event_types.map((eventType) => (
|
||||
<Badge key={eventType.id} variant="outline">
|
||||
{eventType.name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<Badge variant="outline">{t('emotions.labels.noEventType')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{updated ? <p className="text-xs text-slate-400">{t('emotions.labels.updated', { date: updated })}</p> : null}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between gap-2">
|
||||
<Button variant="ghost" onClick={onToggle} className="text-slate-500 hover:text-emerald-600">
|
||||
<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 }} />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyEmotionsState() {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-indigo-200 bg-indigo-50/40 p-12 text-center">
|
||||
<div className="rounded-full bg-white p-4 shadow-inner">
|
||||
<Smile className="h-8 w-8 text-indigo-500" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold text-slate-800">{t('emotions.empty.title')}</h3>
|
||||
<p className="text-sm text-slate-600">{t('emotions.empty.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmotionSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse rounded-2xl border border-slate-100 bg-white/80 p-6 shadow-sm">
|
||||
<div className="mb-3 h-4 w-24 rounded bg-slate-200" />
|
||||
<div className="mb-2 h-5 w-40 rounded bg-slate-200" />
|
||||
<div className="h-16 rounded bg-slate-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmotionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -308,12 +276,11 @@ function EmotionDialog({
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('emotions.dialogs.createTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="emotion-name">{t('emotions.dialogs.name')}</Label>
|
||||
<Input
|
||||
@@ -323,17 +290,14 @@ function EmotionDialog({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="emotion-description">{t('emotions.dialogs.description')}</Label>
|
||||
<textarea
|
||||
<Input
|
||||
id="emotion-description"
|
||||
value={form.description}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||
className="min-h-[80px] w-full rounded-md border border-slate-200 bg-white/80 p-2 text-sm shadow-sm focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="emotion-icon">{t('emotions.dialogs.icon')}</Label>
|
||||
@@ -347,28 +311,20 @@ function EmotionDialog({
|
||||
<Label htmlFor="emotion-color">{t('emotions.dialogs.color')}</Label>
|
||||
<Input
|
||||
id="emotion-color"
|
||||
type="color"
|
||||
value={form.color}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, color: event.target.value }))}
|
||||
placeholder="#6366f1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-indigo-100 bg-indigo-50/60 p-3">
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50/50 p-3">
|
||||
<div>
|
||||
<Label htmlFor="emotion-active" className="text-sm font-medium text-slate-800">
|
||||
{t('emotions.dialogs.activeLabel')}
|
||||
</Label>
|
||||
<p className="text-sm font-medium text-slate-700">{t('emotions.dialogs.activeLabel')}</p>
|
||||
<p className="text-xs text-slate-500">{t('emotions.dialogs.activeDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="emotion-active"
|
||||
checked={form.is_active}
|
||||
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: Boolean(checked) }))}
|
||||
/>
|
||||
<Switch checked={form.is_active} onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))} />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-end gap-2">
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('emotions.dialogs.cancel')}
|
||||
</Button>
|
||||
@@ -382,3 +338,27 @@ function EmotionDialog({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EmotionSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={`emotion-skeleton-${index}`} className="h-36 animate-pulse rounded-xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyEmotionsState({ 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-slate-200 bg-slate-50/60 p-10 text-center">
|
||||
<h3 className="text-base font-semibold text-slate-800">{t('emotions.empty.title')}</h3>
|
||||
<p className="text-sm text-slate-500">{t('emotions.empty.description')}</p>
|
||||
<Button onClick={onCreate} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t('emotions.actions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
77
resources/js/admin/pages/EngagementPage.tsx
Normal file
77
resources/js/admin/pages/EngagementPage.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { TasksSection } from './TasksPage';
|
||||
import { TaskCollectionsSection } from './TaskCollectionsPage';
|
||||
import { EmotionsSection } from './EmotionsPage';
|
||||
|
||||
const TAB_KEYS = ['tasks', 'collections', 'emotions'] as const;
|
||||
type EngagementTab = (typeof TAB_KEYS)[number];
|
||||
|
||||
function ensureValidTab(value: string | null): EngagementTab {
|
||||
if (value && (TAB_KEYS as readonly string[]).includes(value)) {
|
||||
return value as EngagementTab;
|
||||
}
|
||||
return 'tasks';
|
||||
}
|
||||
|
||||
export default function EngagementPage(): JSX.Element {
|
||||
const { t } = useTranslation('management');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const initialTab = React.useMemo(() => ensureValidTab(searchParams.get('tab')), [searchParams]);
|
||||
const [activeTab, setActiveTab] = React.useState<EngagementTab>(initialTab);
|
||||
|
||||
const handleTabChange = React.useCallback(
|
||||
(next: string) => {
|
||||
const valid = ensureValidTab(next);
|
||||
setActiveTab(valid);
|
||||
setSearchParams((prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
params.set('tab', valid);
|
||||
return params;
|
||||
});
|
||||
},
|
||||
[setSearchParams]
|
||||
);
|
||||
|
||||
const heading = tc('navigation.engagement');
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={heading}
|
||||
subtitle={t('engagement.subtitle', 'Bündle Aufgaben, Vorlagen und Emotionen für deine Events.')}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-white/60 shadow-sm">
|
||||
<TabsTrigger value="tasks">{tc('navigation.tasks')}</TabsTrigger>
|
||||
<TabsTrigger value="collections">{tc('navigation.collections')}</TabsTrigger>
|
||||
<TabsTrigger value="emotions">{tc('navigation.emotions')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tasks" className="space-y-6">
|
||||
<TasksSection
|
||||
embedded
|
||||
onNavigateToCollections={() => handleTabChange('collections')}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="collections" className="space-y-6">
|
||||
<TaskCollectionsSection
|
||||
embedded
|
||||
onNavigateToTasks={() => handleTabChange('tasks')}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="emotions" className="space-y-6">
|
||||
<EmotionsSection embedded />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Camera, Copy, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
|
||||
import { ArrowLeft, Camera, Heart, Loader2, RefreshCw, Sparkles, QrCode } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -9,19 +8,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
createQrInvite,
|
||||
EventQrInvite,
|
||||
EventQrInviteLayout,
|
||||
EventStats as TenantEventStats,
|
||||
getEvent,
|
||||
getEventQrInvites,
|
||||
getEventStats,
|
||||
TenantEvent,
|
||||
toggleEvent,
|
||||
revokeEventQrInvite,
|
||||
updateEventQrInvite,
|
||||
} from '../api';
|
||||
import QrInviteCustomizationDialog, { QrLayoutCustomization } from './components/QrInviteCustomizationDialog';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import {
|
||||
ADMIN_EVENTS_PATH,
|
||||
@@ -30,13 +22,12 @@ import {
|
||||
ADMIN_EVENT_MEMBERS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_TOOLKIT_PATH,
|
||||
ADMIN_EVENT_INVITES_PATH,
|
||||
} from '../constants';
|
||||
|
||||
interface State {
|
||||
event: TenantEvent | null;
|
||||
stats: TenantEventStats | null;
|
||||
invites: EventQrInvite[];
|
||||
inviteLink: string | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
@@ -51,16 +42,10 @@ export default function EventDetailPage() {
|
||||
const [state, setState] = React.useState<State>({
|
||||
event: null,
|
||||
stats: null,
|
||||
invites: [],
|
||||
inviteLink: null,
|
||||
error: null,
|
||||
loading: true,
|
||||
busy: false,
|
||||
});
|
||||
const [creatingInvite, setCreatingInvite] = React.useState(false);
|
||||
const [revokingId, setRevokingId] = React.useState<number | null>(null);
|
||||
const [customizingInvite, setCustomizingInvite] = React.useState<EventQrInvite | null>(null);
|
||||
const [customizerSaving, setCustomizerSaving] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
@@ -70,22 +55,19 @@ export default function EventDetailPage() {
|
||||
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
try {
|
||||
const [eventData, statsData, qrInvites] = await Promise.all([
|
||||
const [eventData, statsData] = await Promise.all([
|
||||
getEvent(slug),
|
||||
getEventStats(slug),
|
||||
getEventQrInvites(slug),
|
||||
]);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
event: eventData,
|
||||
stats: statsData,
|
||||
invites: qrInvites,
|
||||
loading: false,
|
||||
inviteLink: prev.inviteLink,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (isAuthError(err)) return;
|
||||
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false, invites: [] }));
|
||||
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false }));
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
@@ -113,132 +95,10 @@ export default function EventDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInvite() {
|
||||
if (!slug || creatingInvite) return;
|
||||
setCreatingInvite(true);
|
||||
setState((prev) => ({ ...prev, error: null }));
|
||||
try {
|
||||
const invite = await createQrInvite(slug);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
inviteLink: invite.url,
|
||||
invites: [invite, ...prev.invites.filter((existing) => existing.id !== invite.id)],
|
||||
}));
|
||||
try {
|
||||
await navigator.clipboard.writeText(invite.url);
|
||||
} catch {
|
||||
// clipboard may be unavailable, ignore silently
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erzeugt werden.' }));
|
||||
}
|
||||
}
|
||||
setCreatingInvite(false);
|
||||
}
|
||||
|
||||
async function handleCopy(invite: EventQrInvite) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(invite.url);
|
||||
setState((prev) => ({ ...prev, inviteLink: invite.url }));
|
||||
} catch (err) {
|
||||
console.warn('Clipboard copy failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(invite: EventQrInvite) {
|
||||
if (!slug || invite.revoked_at) return;
|
||||
setRevokingId(invite.id);
|
||||
setState((prev) => ({ ...prev, error: null }));
|
||||
try {
|
||||
const updated = await revokeEventQrInvite(slug, invite.id);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
invites: prev.invites.map((existing) => (existing.id === updated.id ? updated : existing)),
|
||||
}));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht deaktiviert werden.' }));
|
||||
}
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function openCustomizer(invite: EventQrInvite) {
|
||||
setState((prev) => ({ ...prev, error: null }));
|
||||
setCustomizingInvite(invite);
|
||||
}
|
||||
|
||||
function closeCustomizer() {
|
||||
if (customizerSaving) {
|
||||
return;
|
||||
}
|
||||
setCustomizingInvite(null);
|
||||
}
|
||||
|
||||
async function handleApplyCustomization(customization: QrLayoutCustomization) {
|
||||
if (!slug || !customizingInvite) {
|
||||
return;
|
||||
}
|
||||
setCustomizerSaving(true);
|
||||
setState((prev) => ({ ...prev, error: null }));
|
||||
try {
|
||||
const updated = await updateEventQrInvite(slug, customizingInvite.id, {
|
||||
metadata: {
|
||||
layout_customization: customization,
|
||||
},
|
||||
});
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
|
||||
}));
|
||||
setCustomizerSaving(false);
|
||||
setCustomizingInvite(null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht gespeichert werden.' }));
|
||||
}
|
||||
setCustomizerSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetCustomization() {
|
||||
if (!slug || !customizingInvite) {
|
||||
return;
|
||||
}
|
||||
setCustomizerSaving(true);
|
||||
setState((prev) => ({ ...prev, error: null }));
|
||||
try {
|
||||
const updated = await updateEventQrInvite(slug, customizingInvite.id, {
|
||||
metadata: {
|
||||
layout_customization: null,
|
||||
},
|
||||
});
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
|
||||
}));
|
||||
setCustomizerSaving(false);
|
||||
setCustomizingInvite(null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setState((prev) => ({ ...prev, error: 'Anpassungen konnten nicht zurückgesetzt werden.' }));
|
||||
}
|
||||
setCustomizerSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const { event, stats, invites, inviteLink, error, loading, busy } = state;
|
||||
const { event, stats, error, loading, busy } = state;
|
||||
const eventDisplayName = event ? renderName(event.name) : '';
|
||||
const currentCustomization = React.useMemo(() => {
|
||||
if (!customizingInvite) {
|
||||
return null;
|
||||
}
|
||||
const metadata = customizingInvite.metadata as Record<string, unknown> | undefined | null;
|
||||
const raw = metadata?.layout_customization;
|
||||
return raw && typeof raw === 'object' ? (raw as QrLayoutCustomization) : null;
|
||||
}, [customizingInvite]);
|
||||
const activeInvitesCount = event?.active_invites_count ?? 0;
|
||||
const totalInvitesCount = event?.total_invites_count ?? activeInvitesCount;
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
@@ -247,7 +107,7 @@ export default function EventDetailPage() {
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
className="border-pink-200 text-pink-600 hover:bg-pink-50"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurueck zur Liste
|
||||
<ArrowLeft className="h-4 w-4" /> Zurück zur Liste
|
||||
</Button>
|
||||
{event && (
|
||||
<>
|
||||
@@ -272,6 +132,13 @@ export default function EventDetailPage() {
|
||||
>
|
||||
Tasks
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(event.slug))}
|
||||
className="border-amber-200 text-amber-600 hover:bg-amber-50"
|
||||
>
|
||||
QR & Einladungen
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(event.slug))}
|
||||
@@ -286,10 +153,10 @@ export default function EventDetailPage() {
|
||||
|
||||
if (!slug) {
|
||||
return (
|
||||
<AdminLayout title="Event nicht gefunden" subtitle="Bitte waehle ein Event aus der Uebersicht." actions={actions}>
|
||||
<AdminLayout title="Event nicht gefunden" subtitle="Bitte wähle ein Event aus der Übersicht." actions={actions}>
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardContent className="p-6 text-sm text-slate-600">
|
||||
Ohne gueltigen Slug koennen wir keine Daten laden. Kehre zur Event-Liste zurueck und waehle dort ein Event aus.
|
||||
Ohne gültigen Slug können wir keine Daten laden. Kehre zur Event-Liste zurück und wähle dort ein Event aus.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AdminLayout>
|
||||
@@ -319,12 +186,12 @@ export default function EventDetailPage() {
|
||||
<Sparkles className="h-5 w-5 text-pink-500" /> Eventdaten
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Grundlegende Informationen fuer Gaeste und Moderation.
|
||||
Grundlegende Informationen für Gäste und Moderation.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm text-slate-700">
|
||||
<InfoRow label="Slug" value={event.slug} />
|
||||
<InfoRow label="Status" value={event.status === 'published' ? 'Veroeffentlicht' : event.status} />
|
||||
<InfoRow label="Status" value={event.status === 'published' ? 'Veröffentlicht' : event.status} />
|
||||
<InfoRow label="Datum" value={formatDate(event.event_date)} />
|
||||
<InfoRow label="Aktiv" value={event.is_active ? 'Aktiv' : 'Deaktiviert'} />
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
@@ -347,60 +214,35 @@ export default function EventDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card id="qr-invites" className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
|
||||
<Card className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Share2 className="h-5 w-5 text-amber-500" /> QR-Einladungen & Drucklayouts
|
||||
<QrCode className="h-5 w-5 text-amber-500" /> Einladungen & QR
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Erzeuge QR-Codes für eure Gäste und ladet direkt passende A4-Vorlagen – inklusive Branding und Anleitungen –
|
||||
zum Ausdrucken herunter.
|
||||
Steuere QR-Einladungen, Layouts und Branding gesammelt auf einer eigenen Seite.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm text-slate-700">
|
||||
<div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800">
|
||||
<p>
|
||||
Drucke die Layouts aus, platziere sie am Eventort oder teile den QR-Link digital. Du kannst Einladungen
|
||||
jederzeit erneuern oder deaktivieren.
|
||||
Aktive QR-Einladungen: {activeInvitesCount} · Gesamt erstellt: {totalInvitesCount}
|
||||
</p>
|
||||
<p>
|
||||
Bereite deine Drucklayouts vor, personalisiere Texte und Logos und drucke sie direkt aus.
|
||||
</p>
|
||||
{invites.length > 0 && (
|
||||
<p className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-amber-600">
|
||||
Aktive QR-Einladungen: {invites.filter((invite) => invite.is_active && !invite.revoked_at).length} · Gesamt:{' '}
|
||||
{invites.length}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleInvite} disabled={creatingInvite} className="w-full">
|
||||
{creatingInvite ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
|
||||
QR-Einladung erstellen
|
||||
</Button>
|
||||
|
||||
{inviteLink && (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 font-mono text-xs text-amber-800">
|
||||
{inviteLink}
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
className="w-full bg-amber-500 text-white shadow-lg shadow-amber-500/20 hover:bg-amber-500/90"
|
||||
onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(event.slug))}
|
||||
>
|
||||
Einladungen & Layouts verwalten
|
||||
</Button>
|
||||
<p className="text-xs text-slate-500">
|
||||
Du kannst bestehende Layouts duplizieren, Farben anpassen und neue PDFs generieren.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{invites.length > 0 ? (
|
||||
invites.map((invite) => (
|
||||
<InvitationCard
|
||||
key={invite.id}
|
||||
invite={invite}
|
||||
onCopy={() => handleCopy(invite)}
|
||||
onRevoke={() => handleRevoke(invite)}
|
||||
revoking={revokingId === invite.id}
|
||||
onCustomize={() => openCustomizer(invite)}
|
||||
eventName={eventDisplayName}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 bg-white/80 p-4 text-xs text-slate-500">
|
||||
Es gibt noch keine QR-Einladungen. Erstelle jetzt eine Einladung, um Layouts mit QR-Code zu generieren
|
||||
und zu teilen.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -425,21 +267,9 @@ export default function EventDetailPage() {
|
||||
) : (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Event nicht gefunden</AlertTitle>
|
||||
<AlertDescription>Bitte pruefe den Slug und versuche es erneut.</AlertDescription>
|
||||
<AlertDescription>Bitte prüfe den Slug und versuche es erneut.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<QrInviteCustomizationDialog
|
||||
open={Boolean(customizingInvite)}
|
||||
onClose={closeCustomizer}
|
||||
onSubmit={handleApplyCustomization}
|
||||
onReset={handleResetCustomization}
|
||||
saving={customizerSaving}
|
||||
inviteUrl={customizingInvite?.url ?? ''}
|
||||
eventName={eventDisplayName}
|
||||
layouts={customizingInvite?.layouts ?? []}
|
||||
initialCustomization={currentCustomization}
|
||||
/>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -472,220 +302,6 @@ function StatChip({ label, value }: { label: string; value: string | number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function InvitationCard({
|
||||
invite,
|
||||
onCopy,
|
||||
onRevoke,
|
||||
revoking,
|
||||
onCustomize,
|
||||
eventName,
|
||||
}: {
|
||||
invite: EventQrInvite;
|
||||
onCopy: () => void;
|
||||
onRevoke: () => void;
|
||||
revoking: boolean;
|
||||
onCustomize: () => void;
|
||||
eventName: string;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const status = getInviteStatus(invite);
|
||||
const layouts = Array.isArray(invite.layouts) ? invite.layouts : [];
|
||||
const usageLabel = invite.usage_limit ? `${invite.usage_count} / ${invite.usage_limit}` : `${invite.usage_count}`;
|
||||
const metadata = (invite.metadata ?? {}) as Record<string, unknown>;
|
||||
const isAutoGenerated = Boolean(metadata.auto_generated);
|
||||
const customization = (metadata.layout_customization ?? null) as QrLayoutCustomization | null;
|
||||
const preferredLayoutId = customization?.layout_id ?? (layouts[0]?.id ?? null);
|
||||
const hasCustomization = customization ? Object.keys(customization).length > 0 : false;
|
||||
|
||||
const statusClassname =
|
||||
status === 'Aktiv'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: status === 'Abgelaufen'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-slate-200 text-slate-700';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-md shadow-amber-100/40">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900">{invite.label?.trim() || `Einladung #${invite.id}`}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusClassname}`}>{status}</span>
|
||||
{isAutoGenerated ? (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700">
|
||||
Standard
|
||||
</span>
|
||||
) : null}
|
||||
{hasCustomization ? (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
|
||||
{t('tasks.customizer.badge', 'Angepasst')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="break-all rounded-md border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs text-slate-700">
|
||||
{invite.url}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCopy}
|
||||
className="border-amber-200 text-amber-700 hover:bg-amber-100"
|
||||
>
|
||||
<Copy className="mr-1 h-3 w-3" />
|
||||
Link kopieren
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
|
||||
<span>Nutzung: {usageLabel}</span>
|
||||
{invite.expires_at ? <span>Gültig bis {formatDateTime(invite.expires_at)}</span> : null}
|
||||
{invite.created_at ? <span>Erstellt am {formatDateTime(invite.created_at)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={hasCustomization ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={onCustomize}
|
||||
className={hasCustomization ? 'bg-amber-500 text-white hover:bg-amber-500/90 border-amber-200' : 'border-amber-200 text-amber-700 hover:bg-amber-100'}
|
||||
>
|
||||
<Sparkles className="mr-1 h-3 w-3" />
|
||||
{t('tasks.customizer.actionLabel', 'Layout anpassen')}
|
||||
</Button>
|
||||
{invite.layouts_url ? (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-amber-200 text-amber-700 hover:bg-amber-100"
|
||||
>
|
||||
<a href={invite.layouts_url} target="_blank" rel="noreferrer">
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
Layout-Übersicht
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRevoke}
|
||||
disabled={revoking || invite.revoked_at !== null || !invite.is_active}
|
||||
className="text-slate-600 hover:bg-slate-100 disabled:opacity-50"
|
||||
>
|
||||
{revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deaktivieren'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{layouts.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{layouts.map((layout) => (
|
||||
<LayoutPreviewCard
|
||||
key={layout.id}
|
||||
layout={layout}
|
||||
customization={layout.id === preferredLayoutId ? customization : null}
|
||||
selected={layout.id === preferredLayoutId}
|
||||
eventName={eventName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : invite.layouts_url ? (
|
||||
<div className="rounded-xl border border-amber-100 bg-amber-50/60 p-3 text-xs text-amber-800">
|
||||
Für diese Einladung stehen Layouts bereit. Öffne die Übersicht, um PDF- oder SVG-Versionen zu laden.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutPreviewCard({
|
||||
layout,
|
||||
customization,
|
||||
selected,
|
||||
eventName,
|
||||
}: {
|
||||
layout: EventQrInviteLayout;
|
||||
customization: QrLayoutCustomization | null;
|
||||
selected: boolean;
|
||||
eventName: string;
|
||||
}) {
|
||||
const gradient = customization?.background_gradient ?? layout.preview?.background_gradient;
|
||||
const stops = Array.isArray(gradient?.stops) ? gradient?.stops ?? [] : [];
|
||||
const gradientStyle = stops.length
|
||||
? {
|
||||
backgroundImage: `linear-gradient(${gradient?.angle ?? customization?.background_gradient?.angle ?? 135}deg, ${stops.join(', ')})`,
|
||||
}
|
||||
: {
|
||||
backgroundColor: customization?.background_color ?? layout.preview?.background ?? '#F8FAFC',
|
||||
};
|
||||
const textColor = customization?.text_color ?? layout.preview?.text ?? '#0F172A';
|
||||
const badgeColor = customization?.badge_color ?? customization?.accent_color ?? layout.preview?.accent ?? '#0EA5E9';
|
||||
|
||||
const formats = Array.isArray(layout.formats) ? layout.formats : [];
|
||||
const headline = customization?.headline ?? layout.name ?? eventName;
|
||||
const subtitle = customization?.subtitle ?? layout.subtitle ?? '';
|
||||
const description = customization?.description ?? layout.description ?? '';
|
||||
const instructions = customization?.instructions ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden rounded-xl border bg-white shadow-sm ${selected ? 'border-amber-300 ring-2 ring-amber-200' : 'border-amber-100'}`}
|
||||
>
|
||||
<div className="relative h-28">
|
||||
<div className="absolute inset-0" style={gradientStyle} />
|
||||
<div className="absolute inset-0 flex flex-col justify-between p-3 text-xs" style={{ color: textColor }}>
|
||||
<span
|
||||
className="w-fit rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"
|
||||
style={{ backgroundColor: badgeColor, color: '#ffffff' }}
|
||||
>
|
||||
QR-Layout
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold leading-tight">{headline}</div>
|
||||
{subtitle ? <div className="text-[11px] opacity-80">{subtitle}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 p-3">
|
||||
{description ? <p className="text-xs text-slate-600">{description}</p> : null}
|
||||
{instructions.length > 0 ? (
|
||||
<ul className="space-y-1 text-[11px] text-slate-500">
|
||||
{instructions.slice(0, 3).map((item, index) => (
|
||||
<li key={`${layout.id}-instruction-${index}`}>• {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formats.map((format) => {
|
||||
const key = String(format ?? '').toLowerCase();
|
||||
const href = layout.download_urls?.[key] ?? layout.download_urls?.[String(format ?? '')] ?? null;
|
||||
if (!href) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = String(format ?? '').toUpperCase() || 'PDF';
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
key={`${layout.id}-${label}`}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-amber-200 text-amber-700 hover:bg-amber-100"
|
||||
>
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
{label}
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return 'Noch kein Datum';
|
||||
const date = new Date(iso);
|
||||
@@ -695,32 +311,6 @@ function formatDate(iso: string | null): string {
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string | null): string {
|
||||
if (!iso) return 'unbekannt';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'unbekannt';
|
||||
}
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getInviteStatus(invite: EventQrInvite): 'Aktiv' | 'Deaktiviert' | 'Abgelaufen' {
|
||||
if (invite.revoked_at) return 'Deaktiviert';
|
||||
if (invite.expires_at) {
|
||||
const expiry = new Date(invite.expires_at);
|
||||
if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < Date.now()) {
|
||||
return 'Abgelaufen';
|
||||
}
|
||||
}
|
||||
return invite.is_active ? 'Aktiv' : 'Deaktiviert';
|
||||
}
|
||||
|
||||
function renderName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') {
|
||||
return name;
|
||||
|
||||
@@ -200,12 +200,12 @@ export default function EventFormPage() {
|
||||
}
|
||||
|
||||
if (!trimmedSlug) {
|
||||
setError('Bitte waehle einen Slug fuer die Event-URL.');
|
||||
setError('Bitte wähle einen Slug für die Event-URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.eventTypeId) {
|
||||
setError('Bitte waehle einen Event-Typ aus.');
|
||||
setError('Bitte wähle einen Event-Typ aus.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ export default function EventFormPage() {
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Speichern fehlgeschlagen. Bitte pruefe deine Eingaben.');
|
||||
setError('Speichern fehlgeschlagen. Bitte prüfe deine Eingaben.');
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -326,14 +326,14 @@ export default function EventFormPage() {
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
className="border-pink-200 text-pink-600 hover:bg-pink-50"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurueck zur Liste
|
||||
<ArrowLeft className="h-4 w-4" /> Zurück zur Liste
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={isEdit ? 'Event bearbeiten' : 'Neues Event erstellen'}
|
||||
subtitle="Fuelle die wichtigsten Angaben aus und teile dein Event mit Gaesten."
|
||||
subtitle="Fülle die wichtigsten Angaben aus und teile dein Event mit Gästen."
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
@@ -349,7 +349,7 @@ export default function EventFormPage() {
|
||||
<Sparkles className="h-5 w-5 text-pink-500" /> Eventdetails
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Name, URL und Datum bestimmen das Auftreten deines Events im Gaesteportal.
|
||||
Name, URL und Datum bestimmen das Auftreten deines Events im Gästeportal.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -377,8 +377,8 @@ export default function EventFormPage() {
|
||||
onChange={(e) => handleSlugChange(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Diese Kennung wird intern verwendet. Gaeste betreten dein Event ausschliesslich ueber ihre
|
||||
Einladungslinks und die dazugehoerigen QR-Layouts.
|
||||
Diese Kennung wird intern verwendet. Gäste betreten dein Event ausschließlich über ihre
|
||||
Einladungslinks und die dazugehörigen QR-Layouts.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -399,7 +399,7 @@ export default function EventFormPage() {
|
||||
>
|
||||
<SelectTrigger id="event-type">
|
||||
<SelectValue
|
||||
placeholder={eventTypesLoading ? 'Event-Typ wird geladen…' : 'Event-Typ auswaehlen'}
|
||||
placeholder={eventTypesLoading ? 'Event-Typ wird geladen…' : 'Event-Typ auswählen'}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -412,7 +412,7 @@ export default function EventFormPage() {
|
||||
</Select>
|
||||
{!eventTypesLoading && (!eventTypes || eventTypes.length === 0) ? (
|
||||
<p className="text-xs text-amber-600">
|
||||
Keine Event-Typen verfuegbar. Bitte lege einen Typ im Adminbereich an.
|
||||
Keine Event-Typen verfügbar. Bitte lege einen Typ im Adminbereich an.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -506,7 +506,7 @@ export default function EventFormPage() {
|
||||
Event sofort veroeffentlichen
|
||||
</Label>
|
||||
<p className="text-xs text-slate-600">
|
||||
Aktiviere diese Option, wenn Gaeste das Event direkt sehen sollen. Du kannst den Status spaeter aendern.
|
||||
Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
516
resources/js/admin/pages/EventInvitesPage.tsx
Normal file
516
resources/js/admin/pages/EventInvitesPage.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Copy, Loader2, QrCode, RefreshCw, Share2, Sparkles, X } from 'lucide-react';
|
||||
|
||||
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 { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
createQrInvite,
|
||||
EventQrInvite,
|
||||
getEvent,
|
||||
getEventQrInvites,
|
||||
revokeEventQrInvite,
|
||||
TenantEvent,
|
||||
updateEventQrInvite,
|
||||
EventQrInviteLayout,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import {
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENT_TOOLKIT_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
} from '../constants';
|
||||
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
|
||||
|
||||
interface PageState {
|
||||
event: TenantEvent | null;
|
||||
invites: EventQrInvite[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export default function EventInvitesPage(): JSX.Element {
|
||||
const { slug } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const [state, setState] = React.useState<PageState>({ event: null, invites: [], loading: true, error: null });
|
||||
const [creatingInvite, setCreatingInvite] = React.useState(false);
|
||||
const [revokingId, setRevokingId] = React.useState<number | null>(null);
|
||||
const [selectedInviteId, setSelectedInviteId] = React.useState<number | null>(null);
|
||||
const [copiedInviteId, setCopiedInviteId] = React.useState<number | null>(null);
|
||||
const [customizerSaving, setCustomizerSaving] = React.useState(false);
|
||||
const [customizerResetting, setCustomizerResetting] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
setState({ event: null, invites: [], loading: false, error: 'Kein Event-Slug angegeben.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
try {
|
||||
const [eventData, invitesData] = await Promise.all([getEvent(slug), getEventQrInvites(slug)]);
|
||||
setState({ event: eventData, invites: invitesData, loading: false, error: null });
|
||||
setSelectedInviteId((current) => current ?? invitesData[0]?.id ?? null);
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState({ event: null, invites: [], loading: false, error: 'QR-Einladungen konnten nicht geladen werden.' });
|
||||
}
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const event = state.event;
|
||||
const eventName = event ? renderEventName(event.name) : t('toolkit.titleFallback', 'Event');
|
||||
|
||||
const selectedInvite = React.useMemo(
|
||||
() => state.invites.find((invite) => invite.id === selectedInviteId) ?? null,
|
||||
[state.invites, selectedInviteId]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state.invites.length === 0) {
|
||||
setSelectedInviteId(null);
|
||||
return;
|
||||
}
|
||||
setSelectedInviteId((current) => {
|
||||
if (current && state.invites.some((invite) => invite.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return state.invites[0]?.id ?? null;
|
||||
});
|
||||
}, [state.invites]);
|
||||
|
||||
const currentCustomization = React.useMemo(() => {
|
||||
if (!selectedInvite) {
|
||||
return null;
|
||||
}
|
||||
const metadata = selectedInvite.metadata as Record<string, unknown> | undefined | null;
|
||||
const raw = metadata?.layout_customization;
|
||||
return raw && typeof raw === 'object' ? (raw as QrLayoutCustomization) : null;
|
||||
}, [selectedInvite]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedInvite) {
|
||||
console.debug('[Invites] Selected invite', selectedInvite.id, selectedInvite.layouts, selectedInvite.layouts_url);
|
||||
}
|
||||
}, [selectedInvite]);
|
||||
|
||||
const inviteCountSummary = React.useMemo(() => {
|
||||
const active = state.invites.filter((invite) => invite.is_active && !invite.revoked_at).length;
|
||||
const total = state.invites.length;
|
||||
return { active, total };
|
||||
}, [state.invites]);
|
||||
|
||||
async function handleCreateInvite() {
|
||||
if (!slug || creatingInvite) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingInvite(true);
|
||||
setState((prev) => ({ ...prev, error: null }));
|
||||
try {
|
||||
const invite = await createQrInvite(slug);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
invites: [invite, ...prev.invites.filter((existing) => existing.id !== invite.id)],
|
||||
}));
|
||||
setSelectedInviteId(invite.id);
|
||||
try {
|
||||
await navigator.clipboard.writeText(invite.url);
|
||||
setCopiedInviteId(invite.id);
|
||||
} catch {
|
||||
// ignore clipboard failures
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erstellt werden.' }));
|
||||
}
|
||||
} finally {
|
||||
setCreatingInvite(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy(invite: EventQrInvite) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(invite.url);
|
||||
setCopiedInviteId(invite.id);
|
||||
} catch (error) {
|
||||
console.warn('[Invites] Clipboard copy failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copiedInviteId) return;
|
||||
const timeout = setTimeout(() => setCopiedInviteId(null), 3000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [copiedInviteId]);
|
||||
|
||||
async function handleRevoke(invite: EventQrInvite) {
|
||||
if (!slug || invite.revoked_at) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRevokingId(invite.id);
|
||||
setState((prev) => ({ ...prev, error: null }));
|
||||
try {
|
||||
const updated = await revokeEventQrInvite(slug, invite.id);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
invites: prev.invites.map((existing) => (existing.id === updated.id ? updated : existing)),
|
||||
}));
|
||||
if (selectedInviteId === invite.id && !updated.is_active) {
|
||||
setSelectedInviteId((prevId) => (prevId === updated.id ? null : prevId));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht deaktiviert werden.' }));
|
||||
}
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveCustomization(customization: QrLayoutCustomization) {
|
||||
if (!slug || !selectedInvite) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCustomizerSaving(true);
|
||||
setState((prev) => ({ ...prev, error: null }));
|
||||
try {
|
||||
const updated = await updateEventQrInvite(slug, selectedInvite.id, {
|
||||
metadata: { layout_customization: customization },
|
||||
});
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
|
||||
}));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({ ...prev, error: 'Anpassung konnte nicht gespeichert werden.' }));
|
||||
}
|
||||
} finally {
|
||||
setCustomizerSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetCustomization() {
|
||||
if (!slug || !selectedInvite) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCustomizerResetting(true);
|
||||
setState((prev) => ({ ...prev, error: null }));
|
||||
try {
|
||||
const updated = await updateEventQrInvite(slug, selectedInvite.id, {
|
||||
metadata: { layout_customization: null },
|
||||
});
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
|
||||
}));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({ ...prev, error: 'Anpassungen konnten nicht zurückgesetzt werden.' }));
|
||||
}
|
||||
} finally {
|
||||
setCustomizerResetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600 hover:bg-pink-50">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('invites.actions.backToList', 'Zurück zur Übersicht')}
|
||||
</Button>
|
||||
{slug ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))} className="border-slate-200 text-slate-600 hover:bg-slate-50">
|
||||
{t('invites.actions.backToEvent', 'Event öffnen')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug))} className="border-sky-200 text-sky-700 hover:bg-sky-50">
|
||||
{t('toolkit.actions.moderate', 'Fotos moderieren')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(slug))} className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
|
||||
{t('toolkit.actions.backToEvent', 'Event-Day Toolkit')}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={eventName}
|
||||
subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')}
|
||||
actions={actions}
|
||||
>
|
||||
{state.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('invites.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
||||
<AlertDescription>{state.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
|
||||
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<QrCode className="h-5 w-5 text-amber-500" />
|
||||
{t('invites.cardTitle', 'QR-Einladungen & Layouts')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('invites.cardDescription', 'Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.')}
|
||||
</CardDescription>
|
||||
<div className="rounded-lg border border-amber-100 bg-amber-50/70 px-3 py-2 text-xs text-amber-700">
|
||||
{t('invites.summary.active', 'Aktive Einladungen')}: {inviteCountSummary.active} ·{' '}
|
||||
{t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" onClick={() => void load()} disabled={state.loading} className="border-amber-200 text-amber-700 hover:bg-amber-100">
|
||||
{state.loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
{t('invites.actions.refresh', 'Aktualisieren')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateInvite}
|
||||
disabled={creatingInvite}
|
||||
className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white shadow-lg shadow-amber-500/20"
|
||||
>
|
||||
{creatingInvite ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Share2 className="mr-2 h-4 w-4" />}
|
||||
{t('invites.actions.create', 'Neue Einladung erstellen')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{state.loading ? (
|
||||
<InviteSkeleton />
|
||||
) : state.invites.length === 0 ? (
|
||||
<EmptyState onCreate={handleCreateInvite} />
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{state.invites.map((invite) => (
|
||||
<InviteListCard
|
||||
key={invite.id}
|
||||
invite={invite}
|
||||
onSelect={() => setSelectedInviteId(invite.id)}
|
||||
onCopy={() => handleCopy(invite)}
|
||||
onRevoke={() => handleRevoke(invite)}
|
||||
selected={invite.id === selectedInvite?.id}
|
||||
revoking={revokingId === invite.id}
|
||||
copied={copiedInviteId === invite.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<InviteLayoutCustomizerPanel
|
||||
invite={selectedInvite ?? null}
|
||||
eventName={eventName}
|
||||
saving={customizerSaving}
|
||||
resetting={customizerResetting}
|
||||
onSave={handleSaveCustomization}
|
||||
onReset={handleResetCustomization}
|
||||
initialCustomization={currentCustomization}
|
||||
/>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteListCard({
|
||||
invite,
|
||||
selected,
|
||||
onSelect,
|
||||
onCopy,
|
||||
onRevoke,
|
||||
revoking,
|
||||
copied,
|
||||
}: {
|
||||
invite: EventQrInvite;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onCopy: () => void;
|
||||
onRevoke: () => void;
|
||||
revoking: boolean;
|
||||
copied: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const status = getInviteStatus(invite);
|
||||
const metadata = (invite.metadata ?? {}) as Record<string, unknown>;
|
||||
const customization = (metadata.layout_customization ?? null) as QrLayoutCustomization | null;
|
||||
const preferredLayoutId = customization?.layout_id ?? invite.layouts[0]?.id ?? null;
|
||||
const isAutoGenerated = Boolean(metadata.auto_generated);
|
||||
const usageLabel = invite.usage_limit ? `${invite.usage_count} / ${invite.usage_limit}` : `${invite.usage_count}`;
|
||||
|
||||
const layoutsById = React.useMemo(() => {
|
||||
const map = new Map<string, EventQrInviteLayout>();
|
||||
invite.layouts.forEach((layout) => map.set(layout.id, layout));
|
||||
return map;
|
||||
}, [invite.layouts]);
|
||||
|
||||
const layoutName = preferredLayoutId ? layoutsById.get(preferredLayoutId)?.name ?? invite.layouts[0]?.name ?? '' : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
className={`flex flex-col gap-3 rounded-2xl border p-4 transition-shadow ${selected ? 'border-amber-400 bg-amber-50/70 shadow-lg shadow-amber-200/30' : 'border-slate-200 bg-white/80 hover:border-amber-200'}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900">{invite.label?.trim() || `Einladung #${invite.id}`}</span>
|
||||
<Badge variant="outline" className={statusBadgeClass(status)}>
|
||||
{status}
|
||||
</Badge>
|
||||
{isAutoGenerated ? (
|
||||
<Badge variant="secondary" className="bg-slate-200 text-slate-700">{t('invites.labels.standard', 'Standard')}</Badge>
|
||||
) : null}
|
||||
{customization ? (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-700">{t('tasks.customizer.badge', 'Angepasst')}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="break-all rounded-lg border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs text-slate-700">
|
||||
{invite.url}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
className={copied ? 'border-emerald-300 text-emerald-700 hover:bg-emerald-50' : 'border-amber-200 text-amber-700 hover:bg-amber-100'}
|
||||
>
|
||||
<Copy className="mr-1 h-3 w-3" />
|
||||
{copied ? t('invites.actions.copied', 'Kopiert!') : t('invites.actions.copy', 'Link kopieren')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1 text-xs text-slate-500 sm:grid-cols-2">
|
||||
<span>
|
||||
{t('invites.labels.usage', 'Nutzung')}: {usageLabel}
|
||||
</span>
|
||||
<span>
|
||||
{t('invites.labels.layout', 'Layout')}: {layoutName || t('invites.labels.layoutFallback', 'Standard')}
|
||||
</span>
|
||||
{invite.expires_at ? <span>{t('invites.labels.validUntil', 'Gültig bis')} {formatDateTime(invite.expires_at)}</span> : null}
|
||||
{invite.created_at ? <span>{t('invites.labels.createdAt', 'Erstellt am')} {formatDateTime(invite.created_at)}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{selected ? (
|
||||
<Badge variant="outline" className="border-amber-300 bg-amber-100/70 text-amber-700">
|
||||
{t('invites.labels.selected', 'Aktuell ausgewählt')}
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">{t('invites.labels.tapToEdit', 'Zum Anpassen auswählen')}</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRevoke();
|
||||
}}
|
||||
disabled={revoking || invite.revoked_at !== null || !invite.is_active}
|
||||
className="text-slate-500 hover:text-rose-500 disabled:opacity-50"
|
||||
>
|
||||
{revoking ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <X className="mr-1 h-4 w-4" />}
|
||||
{t('invites.actions.deactivate', 'Deaktivieren')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={`invite-skeleton-${index}`} className="h-32 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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-slate-200 bg-slate-50/60 p-10 text-center">
|
||||
<h3 className="text-base font-semibold text-slate-800">{t('invites.empty.title', 'Noch keine Einladungen')}</h3>
|
||||
<p className="text-sm text-slate-500">{t('invites.empty.copy', 'Erstelle eine Einladung, 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')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderEventName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') {
|
||||
return name;
|
||||
}
|
||||
if (name && typeof name === 'object') {
|
||||
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
|
||||
}
|
||||
return 'Event';
|
||||
}
|
||||
|
||||
function getInviteStatus(invite: EventQrInvite): 'Aktiv' | 'Deaktiviert' | 'Abgelaufen' {
|
||||
if (invite.revoked_at) return 'Deaktiviert';
|
||||
if (invite.expires_at) {
|
||||
const expiry = new Date(invite.expires_at);
|
||||
if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < Date.now()) {
|
||||
return 'Abgelaufen';
|
||||
}
|
||||
}
|
||||
return invite.is_active ? 'Aktiv' : 'Deaktiviert';
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
if (status === 'Aktiv') {
|
||||
return 'bg-emerald-100 text-emerald-700 border-emerald-200';
|
||||
}
|
||||
if (status === 'Abgelaufen') {
|
||||
return 'bg-orange-100 text-orange-700 border-orange-200';
|
||||
}
|
||||
return 'bg-slate-200 text-slate-700 border-slate-300';
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string | null): string {
|
||||
if (!iso) return 'unbekannt';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'unbekannt';
|
||||
}
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
@@ -79,12 +79,12 @@ export default function EventPhotosPage() {
|
||||
|
||||
if (!slug) {
|
||||
return (
|
||||
<AdminLayout title="Fotos moderieren" subtitle="Bitte waehle ein Event aus der Uebersicht." actions={null}>
|
||||
<AdminLayout title="Fotos moderieren" subtitle="Bitte wähle ein Event aus der Übersicht." actions={null}>
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardContent className="p-6 text-sm text-slate-600">
|
||||
Kein Slug in der URL gefunden. Kehre zur Event-Liste zurueck und waehle dort ein Event aus.
|
||||
Kein Slug in der URL gefunden. Kehre zur Event-Liste zurück und wähle dort ein Event aus.
|
||||
<Button className="mt-4" onClick={() => navigate(ADMIN_EVENTS_PATH)}>
|
||||
Zurueck zur Liste
|
||||
Zurück zur Liste
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -98,7 +98,7 @@ export default function EventPhotosPage() {
|
||||
onClick={() => slug && navigate(ADMIN_EVENT_VIEW_PATH(slug))}
|
||||
className="border-pink-200 text-pink-600 hover:bg-pink-50"
|
||||
>
|
||||
Zurueck zum Event
|
||||
Zurück zum Event
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function EventPhotosPage() {
|
||||
<Camera className="h-5 w-5 text-sky-500" /> Galerie
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Klick auf ein Foto, um es hervorzuheben oder zu loeschen.
|
||||
Klick auf ein Foto, um es hervorzuheben oder zu löschen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -164,7 +164,7 @@ export default function EventPhotosPage() {
|
||||
disabled={busyId === photo.id}
|
||||
>
|
||||
{busyId === photo.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
Loeschen
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,7 +195,7 @@ function EmptyGallery() {
|
||||
<Camera className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">Noch keine Fotos vorhanden</h3>
|
||||
<p className="text-sm text-slate-600">Motiviere deine Gaeste zum Hochladen - hier erscheint anschliessend die Galerie.</p>
|
||||
<p className="text-sm text-slate-600">Motiviere deine Gäste zum Hochladen - hier erscheint anschließend die Galerie.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Sparkles,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
QrCode,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENT_INVITES_PATH,
|
||||
} from '../constants';
|
||||
import {
|
||||
EventToolkit,
|
||||
@@ -92,6 +94,10 @@ export default function EventToolkitPage(): JSX.Element {
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t('toolkit.actions.manageTasks', 'Tasks öffnen')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(slug ?? ''))}>
|
||||
<QrCode className="h-4 w-4" />
|
||||
{t('toolkit.actions.manageInvites', 'QR-Einladungen')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => void load()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
{t('toolkit.actions.refresh', 'Aktualisieren')}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function EventsPage() {
|
||||
setRows(await getEvents());
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Laden fehlgeschlagen. Bitte spaeter erneut versuchen.');
|
||||
setError('Laden fehlgeschlagen. Bitte später erneut versuchen.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -73,7 +73,7 @@ export default function EventsPage() {
|
||||
<Card className="border-0 bg-white/80 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-slate-900">Uebersicht</CardTitle>
|
||||
<CardTitle className="text-xl font-semibold text-slate-900">Übersicht</CardTitle>
|
||||
<CardDescription className="text-slate-600">
|
||||
{rows.length === 0
|
||||
? 'Noch keine Events - starte jetzt und lege dein erstes Event an.'
|
||||
@@ -191,7 +191,7 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Noch kein Event angelegt</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Starte jetzt mit deinem ersten Event und lade Gaeste in dein farbenfrohes Erlebnisportal ein.
|
||||
Starte jetzt mit deinem ersten Event und lade Gäste in dein farbenfrohes Erlebnisportal ein.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function SettingsPage() {
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
className="border-pink-200 text-pink-600 hover:bg-pink-50"
|
||||
>
|
||||
Zurueck zur Uebersicht
|
||||
Zurück zur Übersicht
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -40,14 +40,14 @@ export default function SettingsPage() {
|
||||
<Palette className="h-5 w-5 text-amber-500" /> Darstellung & Account
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Gestalte den Admin-Bereich so farbenfroh wie dein Gaesteportal.
|
||||
Gestalte den Admin-Bereich so farbenfroh wie dein Gästeportal.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-slate-800">Darstellung</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
Wechsel zwischen Hell- und Dunkelmodus oder uebernimm automatisch die Systemeinstellung.
|
||||
Wechsel zwischen Hell- und Dunkelmodus oder übernimm automatisch die Systemeinstellung.
|
||||
</p>
|
||||
<AppearanceToggleDropdown />
|
||||
</section>
|
||||
|
||||
@@ -5,6 +5,15 @@ import { format } from 'date-fns';
|
||||
import { de, enGB } from 'date-fns/locale';
|
||||
import { Layers, Library, Loader2, Plus } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
getTaskCollections,
|
||||
@@ -14,29 +23,8 @@ import {
|
||||
TenantEvent,
|
||||
TenantTaskCollection,
|
||||
} from '../api';
|
||||
import { ADMIN_TASKS_PATH } from '../constants';
|
||||
import { buildEngagementTabPath } from '../constants';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 12;
|
||||
|
||||
@@ -47,7 +35,12 @@ type CollectionsState = {
|
||||
meta: PaginationMeta | null;
|
||||
};
|
||||
|
||||
export default function TaskCollectionsPage(): JSX.Element {
|
||||
export type TaskCollectionsSectionProps = {
|
||||
embedded?: boolean;
|
||||
onNavigateToTasks?: () => void;
|
||||
};
|
||||
|
||||
export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }: TaskCollectionsSectionProps): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
|
||||
@@ -163,27 +156,23 @@ export default function TaskCollectionsPage(): JSX.Element {
|
||||
}
|
||||
|
||||
const showEmpty = !loading && collectionsState.items.length === 0;
|
||||
const locale = i18n.language.startsWith('en') ? enGB : de;
|
||||
|
||||
const title = t('collections.title') ?? 'Task Collections';
|
||||
const subtitle = embedded
|
||||
? t('collections.subtitle') ?? ''
|
||||
: t('collections.subtitle') ?? '';
|
||||
|
||||
const navigateToTasks = React.useCallback(() => {
|
||||
if (onNavigateToTasks) {
|
||||
onNavigateToTasks();
|
||||
return;
|
||||
}
|
||||
navigate(buildEngagementTabPath('tasks'));
|
||||
}, [navigate, onNavigateToTasks]);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={t('collections.title') ?? 'Task Collections'}
|
||||
subtitle={t('collections.subtitle') ?? ''}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_TASKS_PATH)}>
|
||||
<Library className="mr-2 h-4 w-4" />
|
||||
{t('collections.actions.openTasks')}
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
onClick={() => navigate(ADMIN_TASKS_PATH)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('collections.actions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('collections.notifications.error')}</AlertTitle>
|
||||
@@ -199,253 +188,236 @@ export default function TaskCollectionsPage(): JSX.Element {
|
||||
)}
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl text-slate-900">{t('collections.title')}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">{t('collections.subtitle')}</CardDescription>
|
||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Layers className="h-5 w-5 text-pink-500" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">{subtitle}</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={navigateToTasks}>
|
||||
<Library className="mr-2 h-4 w-4" />
|
||||
{t('collections.actions.openTasks')}
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
onClick={() => setScope('tenant')}
|
||||
>
|
||||
{t('collections.scope.tenant')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),220px]">
|
||||
<Input
|
||||
placeholder={t('collections.filters.search') ?? 'Nach Vorlagen suchen'}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setPage(1);
|
||||
setSearch(event.target.value);
|
||||
}}
|
||||
placeholder={t('collections.filters.search') ?? 'Search collections'}
|
||||
className="w-full lg:max-w-md"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Select
|
||||
value={scope}
|
||||
onValueChange={(value) => {
|
||||
setScope(value as ScopeFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<SelectValue placeholder={t('collections.filters.scope')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('collections.filters.allScopes')}</SelectItem>
|
||||
<SelectItem value="global">{t('collections.filters.globalOnly')}</SelectItem>
|
||||
<SelectItem value="tenant">{t('collections.filters.tenantOnly')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={scope} onValueChange={(value: ScopeFilter) => { setPage(1); setScope(value); }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('collections.filters.allScopes')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('collections.filters.allScopes')}</SelectItem>
|
||||
<SelectItem value="tenant">{t('collections.filters.tenantOnly')}</SelectItem>
|
||||
<SelectItem value="global">{t('collections.filters.globalOnly')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<CollectionSkeleton />
|
||||
<CollectionsSkeleton />
|
||||
) : showEmpty ? (
|
||||
<EmptyCollectionsState onCreate={() => navigate(ADMIN_TASKS_PATH)} />
|
||||
<EmptyCollectionsState onCreate={navigateToTasks} />
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{collectionsState.items.map((collection) => (
|
||||
<CollectionCard
|
||||
<TaskCollectionCard
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
locale={locale}
|
||||
onImport={() => openImportDialog(collection)}
|
||||
onNavigateToTasks={navigateToTasks}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collectionsState.meta && collectionsState.meta.last_page > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((prev) => Math.max(prev - 1, 1))}
|
||||
>
|
||||
{t('collections.pagination.prev')}
|
||||
</Button>
|
||||
<span className="text-xs text-slate-500">
|
||||
{collectionsState.meta && collectionsState.meta.last_page > 1 ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-100 pt-4 text-sm">
|
||||
<div className="text-slate-500">
|
||||
{t('collections.pagination.page', {
|
||||
current: collectionsState.meta.current_page,
|
||||
total: collectionsState.meta.last_page,
|
||||
current: collectionsState.meta.current_page ?? 1,
|
||||
total: collectionsState.meta.last_page ?? 1,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page >= collectionsState.meta.last_page}
|
||||
onClick={() => setPage((prev) => Math.min(prev + 1, collectionsState.meta!.last_page))}
|
||||
>
|
||||
{t('collections.pagination.next')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((value) => Math.max(value - 1, 1))}
|
||||
disabled={(collectionsState.meta.current_page ?? 1) <= 1}
|
||||
>
|
||||
{t('collections.pagination.prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((value) => Math.min(value + 1, collectionsState.meta?.last_page ?? value + 1))}
|
||||
disabled={(collectionsState.meta.current_page ?? 1) >= (collectionsState.meta.last_page ?? 1)}
|
||||
>
|
||||
{t('collections.pagination.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ImportDialog
|
||||
<ImportCollectionDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
collection={selectedCollection}
|
||||
events={events}
|
||||
eventError={eventError}
|
||||
eventsLoading={eventsLoading}
|
||||
selectedEventSlug={selectedEventSlug}
|
||||
onEventChange={setSelectedEventSlug}
|
||||
onSelectedEventChange={setSelectedEventSlug}
|
||||
onSubmit={handleImport}
|
||||
importing={importing}
|
||||
error={eventError}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskCollectionsPage(): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<AdminLayout
|
||||
title={t('collections.title') ?? 'Task Collections'}
|
||||
subtitle={t('collections.subtitle') ?? ''}
|
||||
>
|
||||
<TaskCollectionsSection onNavigateToTasks={() => navigate(buildEngagementTabPath('tasks'))} />
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionCard({
|
||||
function TaskCollectionCard({
|
||||
collection,
|
||||
locale,
|
||||
onImport,
|
||||
onNavigateToTasks,
|
||||
}: {
|
||||
collection: TenantTaskCollection;
|
||||
locale: Locale;
|
||||
onImport: () => void;
|
||||
onNavigateToTasks: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation('management');
|
||||
const locale = i18n.language.startsWith('en') ? enGB : de;
|
||||
const updatedLabel = collection.updated_at
|
||||
? format(new Date(collection.updated_at), 'PP', { locale })
|
||||
: null;
|
||||
const scopeLabel = collection.is_global ? t('collections.scope.global') : t('collections.scope.tenant');
|
||||
const eventTypeLabel = collection.event_type?.name ?? t('collections.filters.allEventTypes');
|
||||
|
||||
const { t } = useTranslation('management');
|
||||
const updatedAt = collection.updated_at ? format(new Date(collection.updated_at), 'Pp', { locale }) : null;
|
||||
return (
|
||||
<Card className="border border-slate-100 bg-white/90 shadow-sm">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={collection.is_global ? 'border-indigo-200 text-indigo-600' : 'border-pink-200 text-pink-600'}
|
||||
>
|
||||
{scopeLabel}
|
||||
</Badge>
|
||||
{eventTypeLabel && (
|
||||
<Badge variant="secondary" className="bg-slate-100 text-slate-700">
|
||||
{eventTypeLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-lg text-slate-900">{collection.name}</CardTitle>
|
||||
{collection.description && (
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{collection.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
<Card className="border border-slate-200/70 bg-white/85 shadow-md shadow-pink-100/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base text-slate-900">{collection.name}</CardTitle>
|
||||
{collection.description ? (
|
||||
<CardDescription className="text-xs text-slate-500">{collection.description}</CardDescription>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between text-sm text-slate-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4" />
|
||||
<span>{t('collections.labels.taskCount', { count: collection.tasks_count })}</span>
|
||||
<CardContent className="space-y-3 text-sm text-slate-600">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={collection.is_global ? 'secondary' : 'default'}>
|
||||
{collection.is_global ? t('collections.scope.global') : t('collections.scope.tenant')}
|
||||
</Badge>
|
||||
<Badge variant="outline">{t('collections.labels.taskCount', { count: collection.tasks_count })}</Badge>
|
||||
{collection.event_type ? (
|
||||
<Badge variant="outline">{collection.event_type.name}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{updatedLabel && <span>{t('collections.labels.updated', { date: updatedLabel })}</span>}
|
||||
{updatedAt ? (
|
||||
<p className="text-xs text-slate-400">{t('collections.labels.updated', { date: updatedAt })}</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button variant="outline" onClick={onImport}>
|
||||
<CardFooter className="flex flex-wrap gap-2">
|
||||
<Button className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white" onClick={onImport}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t('collections.actions.import')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onNavigateToTasks}>
|
||||
{t('collections.actions.openTasks')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyCollectionsState({ onCreate }: { onCreate: () => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-pink-200 bg-pink-50/40 p-12 text-center">
|
||||
<div className="rounded-full bg-white p-4 shadow-inner">
|
||||
<Layers className="h-8 w-8 text-pink-500" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold text-slate-800">{t('collections.empty.title')}</h3>
|
||||
<p className="text-sm text-slate-600">{t('collections.empty.description')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('collections.actions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse rounded-2xl border border-slate-100 bg-white/80 p-6 shadow-sm">
|
||||
<div className="mb-3 h-4 w-24 rounded bg-slate-200" />
|
||||
<div className="mb-2 h-5 w-40 rounded bg-slate-200" />
|
||||
<div className="h-16 rounded bg-slate-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportDialog({
|
||||
function ImportCollectionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
collection,
|
||||
events,
|
||||
eventsLoading,
|
||||
eventError,
|
||||
selectedEventSlug,
|
||||
onEventChange,
|
||||
onSelectedEventChange,
|
||||
onSubmit,
|
||||
importing,
|
||||
error,
|
||||
locale,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
collection: TenantTaskCollection | null;
|
||||
events: TenantEvent[];
|
||||
eventsLoading: boolean;
|
||||
eventError: string | null;
|
||||
selectedEventSlug: string;
|
||||
onEventChange: (value: string) => void;
|
||||
onSelectedEventChange: (slug: string) => void;
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
importing: boolean;
|
||||
error: string | null;
|
||||
locale: Locale;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation('management');
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('collections.dialogs.importTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="collection-name">{t('collections.dialogs.collectionLabel')}</Label>
|
||||
<Input id="collection-name" value={collection?.name ?? ''} readOnly disabled className="bg-slate-100" />
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('collections.dialogs.collectionLabel')}</Label>
|
||||
<p className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
||||
{collection?.name ?? 'Unbekannte Sammlung'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="collection-event">{t('collections.dialogs.selectEvent')}</Label>
|
||||
<Select
|
||||
value={selectedEventSlug}
|
||||
onValueChange={onEventChange}
|
||||
disabled={eventsLoading || events.length === 0}
|
||||
>
|
||||
<Select value={selectedEventSlug} onValueChange={onSelectedEventChange} disabled={eventsLoading || !events.length}>
|
||||
<SelectTrigger id="collection-event">
|
||||
<SelectValue placeholder={t('collections.dialogs.selectEvent') ?? 'Event auswählen'} />
|
||||
<SelectValue placeholder={eventsLoading ? t('collections.errors.eventsLoad') : t('collections.dialogs.selectEvent')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{events.map((event) => (
|
||||
<SelectItem key={event.slug} value={event.slug}>
|
||||
{formatEventLabel(event, i18n.language)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{events.map((event) => {
|
||||
const eventDate = event.event_date ? format(new Date(event.event_date), 'PPP', { locale }) : null;
|
||||
return (
|
||||
<SelectItem key={event.slug} value={event.slug}>
|
||||
{event.name && typeof event.name === 'string' ? event.name : event.slug}
|
||||
{eventDate ? ` · ${eventDate}` : ''}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{events.length === 0 && !eventsLoading && (
|
||||
<p className="text-xs text-slate-500">
|
||||
{t('collections.errors.noEvents') ?? 'Noch keine Events vorhanden.'}
|
||||
</p>
|
||||
)}
|
||||
{eventError && <p className="text-xs text-red-500">{eventError}</p>}
|
||||
{error ? <p className="text-xs text-rose-600">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-end gap-2">
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('collections.dialogs.cancel')}
|
||||
</Button>
|
||||
@@ -460,33 +432,26 @@ function ImportDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function formatEventLabel(event: TenantEvent, language: string): string {
|
||||
const locales = [language, language?.split('-')[0], 'de', 'en'].filter(Boolean) as string[];
|
||||
|
||||
let name: string | undefined;
|
||||
if (typeof event.name === 'string') {
|
||||
name = event.name;
|
||||
} else if (event.name && typeof event.name === 'object') {
|
||||
for (const locale of locales) {
|
||||
const value = (event.name as Record<string, string>)[locale!];
|
||||
if (value) {
|
||||
name = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!name) {
|
||||
const first = Object.values(event.name as Record<string, string>)[0];
|
||||
if (first) {
|
||||
name = first;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const eventDate = event.event_date ? new Date(event.event_date) : null;
|
||||
if (!eventDate) {
|
||||
return name ?? event.slug;
|
||||
}
|
||||
|
||||
const locale = language.startsWith('en') ? enGB : de;
|
||||
return `${name ?? event.slug} (${format(eventDate, 'PP', { locale })})`;
|
||||
function CollectionsSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={`collection-skeleton-${index}`} className="h-40 animate-pulse rounded-xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyCollectionsState({ 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-slate-200 bg-slate-50/60 p-10 text-center">
|
||||
<h3 className="text-base font-semibold text-slate-800">{t('collections.empty.title')}</h3>
|
||||
<p className="text-sm text-slate-500">{t('collections.empty.description')}</p>
|
||||
<Button onClick={onCreate} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t('collections.actions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
updateTask,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_TASK_COLLECTIONS_PATH } from '../constants';
|
||||
import { buildEngagementTabPath } from '../constants';
|
||||
|
||||
type TaskFormState = {
|
||||
title: string;
|
||||
@@ -42,9 +42,15 @@ const INITIAL_FORM: TaskFormState = {
|
||||
is_completed: false,
|
||||
};
|
||||
|
||||
export default function TasksPage() {
|
||||
export type TasksSectionProps = {
|
||||
embedded?: boolean;
|
||||
onNavigateToCollections?: () => void;
|
||||
};
|
||||
|
||||
export function TasksSection({ embedded = false, onNavigateToCollections }: TasksSectionProps): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const [tasks, setTasks] = React.useState<TenantTask[]>([]);
|
||||
const [meta, setMeta] = React.useState<PaginationMeta | null>(null);
|
||||
const [page, setPage] = React.useState(1);
|
||||
@@ -83,13 +89,13 @@ export default function TasksPage() {
|
||||
};
|
||||
}, [page, search]);
|
||||
|
||||
function openCreate() {
|
||||
const openCreate = React.useCallback(() => {
|
||||
setEditingTask(null);
|
||||
setForm(INITIAL_FORM);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
function openEdit(task: TenantTask) {
|
||||
const openEdit = React.useCallback((task: TenantTask) => {
|
||||
setEditingTask(task);
|
||||
setForm({
|
||||
title: task.title,
|
||||
@@ -99,7 +105,15 @@ export default function TasksPage() {
|
||||
is_completed: task.is_completed,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNavigateToCollections = React.useCallback(() => {
|
||||
if (onNavigateToCollections) {
|
||||
onNavigateToCollections();
|
||||
return;
|
||||
}
|
||||
navigate(buildEngagementTabPath('collections'));
|
||||
}, [navigate, onNavigateToCollections]);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -137,7 +151,7 @@ export default function TasksPage() {
|
||||
}
|
||||
|
||||
async function handleDelete(taskId: number) {
|
||||
if (!window.confirm('Task wirklich loeschen?')) {
|
||||
if (!window.confirm('Task wirklich löschen?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,7 +160,7 @@ export default function TasksPage() {
|
||||
setTasks((prev) => prev.filter((task) => task.id !== taskId));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Task konnte nicht geloescht werden.');
|
||||
setError('Task konnte nicht gelöscht werden.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,25 +179,13 @@ export default function TasksPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const title = embedded ? 'Aufgaben' : 'Task Bibliothek';
|
||||
const subtitle = embedded
|
||||
? 'Plane Aufgaben, Aktionen und Highlights für deine Gäste.'
|
||||
: 'Weise Aufgaben zu und tracke Fortschritt rund um deine Events.';
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title="Task Bibliothek"
|
||||
subtitle="Weise Aufgaben zu und tracke Fortschritt rund um deine Events."
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_TASK_COLLECTIONS_PATH)}>
|
||||
{t('navigation.collections')}
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Neu
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Fehler</AlertTitle>
|
||||
@@ -192,76 +194,169 @@ export default function TasksPage() {
|
||||
)}
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl text-slate-900">Tasks verwalten</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Erstelle Aufgaben und ordne sie deinen Events zu.
|
||||
</CardDescription>
|
||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl text-slate-900">{title}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">{subtitle}</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={handleNavigateToCollections}>
|
||||
{t('navigation.collections')}
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Neu
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Input
|
||||
placeholder="Nach Tasks suchen..."
|
||||
placeholder="Nach Aufgaben suchen ..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="sm:max-w-sm"
|
||||
onChange={(event) => {
|
||||
setPage(1);
|
||||
setSearch(event.target.value);
|
||||
}}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)}>
|
||||
Events oeffnen
|
||||
</Button>
|
||||
{meta && meta.total > 0 ? (
|
||||
<div className="text-xs text-slate-500">
|
||||
Seite {meta.current_page} von {meta.last_page} · {meta.total} Einträge
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<TaskSkeleton />
|
||||
<TasksSkeleton />
|
||||
) : tasks.length === 0 ? (
|
||||
<EmptyTasksState onCreate={openCreate} />
|
||||
<EmptyState onCreate={openCreate} />
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-3">
|
||||
{tasks.map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onToggle={() => void toggleCompletion(task)}
|
||||
onToggle={() => toggleCompletion(task)}
|
||||
onEdit={() => openEdit(task)}
|
||||
onDelete={() => void handleDelete(task.id)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta && meta.last_page > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((prev) => Math.max(prev - 1, 1))}
|
||||
>
|
||||
Zurueck
|
||||
</Button>
|
||||
<span className="text-xs text-slate-500">
|
||||
Seite {meta.current_page} von {meta.last_page}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page >= meta.last_page}
|
||||
onClick={() => setPage((prev) => Math.min(prev + 1, meta.last_page))}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
{meta && meta.last_page > 1 ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-100 pt-4 text-sm">
|
||||
<div className="text-slate-500">
|
||||
Insgesamt {meta.total} Aufgaben · Seite {meta.current_page} von {meta.last_page}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setPage((page) => Math.max(page - 1, 1))} disabled={meta.current_page <= 1}>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((page) => Math.min(page + 1, meta.last_page ?? page + 1))}
|
||||
disabled={meta.current_page >= (meta.last_page ?? 1)}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TaskDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSubmit={handleSubmit}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
saving={saving}
|
||||
isEditing={Boolean(editingTask)}
|
||||
/>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTask ? 'Task bearbeiten' : 'Neue Task erstellen'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-title">Titel</Label>
|
||||
<Input
|
||||
id="task-title"
|
||||
value={form.title}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-description">Beschreibung</Label>
|
||||
<Input
|
||||
id="task-description"
|
||||
value={form.description}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||
placeholder="Was sollen Gäste machen?"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-priority">Priorität</Label>
|
||||
<Select
|
||||
value={form.priority ?? 'medium'}
|
||||
onValueChange={(value: TaskPayload['priority']) => setForm((prev) => ({ ...prev, priority: value }))}
|
||||
>
|
||||
<SelectTrigger id="task-priority">
|
||||
<SelectValue placeholder="Priorität wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Niedrig</SelectItem>
|
||||
<SelectItem value="medium">Mittel</SelectItem>
|
||||
<SelectItem value="high">Hoch</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-due-date">Fälligkeitsdatum</Label>
|
||||
<Input
|
||||
id="task-due-date"
|
||||
type="date"
|
||||
value={form.due_date}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, due_date: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50/50 p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">Bereits erledigt?</p>
|
||||
<p className="text-xs text-slate-500">Markiere Aufgaben als abgeschlossen, wenn sie nicht mehr sichtbar sein sollen.</p>
|
||||
</div>
|
||||
<Switch checked={form.is_completed} onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_completed: checked }))} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TasksPage(): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { t: tc } = useTranslation('common');
|
||||
return (
|
||||
<AdminLayout
|
||||
title={tc('navigation.tasks')}
|
||||
subtitle="Weise Aufgaben zu und tracke Fortschritt rund um deine Events."
|
||||
>
|
||||
<TasksSection onNavigateToCollections={() => navigate(buildEngagementTabPath('collections'))} />
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -277,207 +372,68 @@ function TaskRow({
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const assignedCount = task.assigned_events_count ?? task.assigned_events?.length ?? 0;
|
||||
const completed = task.is_completed;
|
||||
const isGlobal = task.tenant_id === null;
|
||||
|
||||
const isCompleted = task.is_completed;
|
||||
const statusIcon = isCompleted ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> : <Circle className="h-4 w-4 text-slate-300" />;
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-slate-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-1 items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={isGlobal ? undefined : onToggle}
|
||||
aria-disabled={isGlobal}
|
||||
className={`rounded-full border border-pink-200 bg-white/80 p-2 text-pink-600 shadow-sm transition ${
|
||||
isGlobal ? 'opacity-50 cursor-not-allowed' : 'hover:bg-pink-50'
|
||||
}`}
|
||||
>
|
||||
{completed ? <CheckCircle2 className="h-5 w-5" /> : <Circle className="h-5 w-5" />}
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-slate-200/70 bg-white/80 p-4 shadow-sm shadow-pink-100/20 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<button type="button" onClick={onToggle} className="mt-1 text-slate-500 transition-colors hover:text-emerald-500">
|
||||
{statusIcon}
|
||||
</button>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className={`text-sm font-semibold ${completed ? 'text-slate-500 line-through' : 'text-slate-900'}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
||||
{mapPriority(task.priority)}
|
||||
</Badge>
|
||||
{isGlobal && (
|
||||
<Badge variant="secondary" className="bg-slate-100 text-slate-600">
|
||||
Global
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{task.description && <p className="text-xs text-slate-600">{task.description}</p>}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||
{task.due_date && <span>Faellig: {formatDate(task.due_date)}</span>}
|
||||
<span>Zugeordnet: {assignedCount}</span>
|
||||
<span className="text-sm font-medium text-slate-900">{task.title}</span>
|
||||
{task.priority ? <PriorityBadge priority={task.priority} /> : null}
|
||||
{task.collection_id ? <Badge variant="secondary">Vorlage #{task.collection_id}</Badge> : null}
|
||||
</div>
|
||||
{task.description ? <p className="text-xs text-slate-500">{task.description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onEdit} disabled={isGlobal} className={isGlobal ? 'opacity-50' : ''}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||
<Pencil className="mr-1 h-4 w-4" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className={`text-rose-600 ${isGlobal ? 'opacity-50' : ''}`}
|
||||
disabled={isGlobal}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Button variant="ghost" size="sm" onClick={onDelete} className="text-slate-500 hover:text-rose-500">
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
form,
|
||||
setForm,
|
||||
saving,
|
||||
isEditing,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
form: TaskFormState;
|
||||
setForm: React.Dispatch<React.SetStateAction<TaskFormState>>;
|
||||
saving: boolean;
|
||||
isEditing: boolean;
|
||||
}) {
|
||||
const handleFormSubmit = React.useCallback((event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onSubmit(event);
|
||||
}, [onSubmit]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? 'Task bearbeiten' : 'Neuen Task anlegen'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleFormSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-title">Titel</Label>
|
||||
<Input
|
||||
id="task-title"
|
||||
value={form.title}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-description">Beschreibung</Label>
|
||||
<textarea
|
||||
id="task-description"
|
||||
value={form.description}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||
className="min-h-[80px] w-full rounded-md border border-slate-200 bg-white/80 p-2 text-sm shadow-sm focus:border-pink-300 focus:outline-none focus:ring-2 focus:ring-pink-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-priority">Prioritaet</Label>
|
||||
<Select
|
||||
value={form.priority ?? 'medium'}
|
||||
onValueChange={(value) => setForm((prev) => ({ ...prev, priority: value as TaskPayload['priority'] }))}
|
||||
>
|
||||
<SelectTrigger id="task-priority">
|
||||
<SelectValue placeholder="Prioritaet waehlen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Niedrig</SelectItem>
|
||||
<SelectItem value="medium">Mittel</SelectItem>
|
||||
<SelectItem value="high">Hoch</SelectItem>
|
||||
<SelectItem value="urgent">Dringend</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-due-date">Faellig am</Label>
|
||||
<Input
|
||||
id="task-due-date"
|
||||
type="date"
|
||||
value={form.due_date}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, due_date: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-pink-100 bg-pink-50/60 p-3">
|
||||
<div>
|
||||
<Label htmlFor="task-completed" className="text-sm font-medium text-slate-800">
|
||||
Bereits erledigt
|
||||
</Label>
|
||||
<p className="text-xs text-slate-500">Markiere Task als abgeschlossen.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="task-completed"
|
||||
checked={form.is_completed}
|
||||
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_completed: Boolean(checked) }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
function PriorityBadge({ priority }: { priority: NonNullable<TaskPayload['priority']> }) {
|
||||
const mapping: Record<NonNullable<TaskPayload['priority']>, { label: string; className: string }> = {
|
||||
low: { label: 'Niedrig', className: 'bg-emerald-50 text-emerald-600' },
|
||||
medium: { label: 'Mittel', className: 'bg-amber-50 text-amber-600' },
|
||||
high: { label: 'Hoch', className: 'bg-rose-50 text-rose-600' },
|
||||
};
|
||||
const { label, className } = mapping[priority];
|
||||
return <Badge className={`border-none ${className}`}>{label}</Badge>;
|
||||
}
|
||||
|
||||
function EmptyTasksState({ onCreate }: { onCreate: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-10 text-center">
|
||||
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
|
||||
<Plus className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">Noch keine Tasks angelegt. Lege deinen ersten Task an.</p>
|
||||
<Button onClick={onCreate}>Task erstellen</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskSkeleton() {
|
||||
function TasksSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||||
<div key={`task-skeleton-${index}`} className="h-16 animate-pulse rounded-xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mapPriority(priority: TenantTask['priority']): string {
|
||||
switch (priority) {
|
||||
case 'low':
|
||||
return 'Niedrig';
|
||||
case 'high':
|
||||
return 'Hoch';
|
||||
case 'urgent':
|
||||
return 'Dringend';
|
||||
default:
|
||||
return 'Mittel';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '--';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '--';
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-10 text-center">
|
||||
<h3 className="text-base font-semibold text-slate-800">Noch keine Tasks angelegt</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Starte mit einer neuen Aufgabe oder importiere Aufgabenvorlagen, um deine Gäste zu inspirieren.
|
||||
</p>
|
||||
<Button onClick={onCreate} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Erste Task erstellen
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,727 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, Loader2, Plus, Printer, RotateCcw, Save, UploadCloud } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
import type { EventQrInvite, EventQrInviteLayout } from '../../api';
|
||||
import { authorizedFetch } from '../../auth/tokens';
|
||||
|
||||
export type QrLayoutCustomization = {
|
||||
layout_id?: string;
|
||||
headline?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
badge_label?: string;
|
||||
instructions_heading?: string;
|
||||
instructions?: string[];
|
||||
link_heading?: string;
|
||||
link_label?: string;
|
||||
cta_label?: string;
|
||||
accent_color?: string;
|
||||
text_color?: string;
|
||||
background_color?: string;
|
||||
secondary_color?: string;
|
||||
badge_color?: string;
|
||||
background_gradient?: { angle?: number; stops?: string[] } | null;
|
||||
logo_data_url?: string | null;
|
||||
logo_url?: string | null;
|
||||
};
|
||||
|
||||
type InviteLayoutCustomizerPanelProps = {
|
||||
invite: EventQrInvite | null;
|
||||
eventName: string;
|
||||
saving: boolean;
|
||||
resetting: boolean;
|
||||
onSave: (customization: QrLayoutCustomization) => Promise<void>;
|
||||
onReset: () => Promise<void>;
|
||||
initialCustomization: QrLayoutCustomization | null;
|
||||
};
|
||||
|
||||
const MAX_INSTRUCTIONS = 5;
|
||||
|
||||
export function InviteLayoutCustomizerPanel({
|
||||
invite,
|
||||
eventName,
|
||||
saving,
|
||||
resetting,
|
||||
onSave,
|
||||
onReset,
|
||||
initialCustomization,
|
||||
}: InviteLayoutCustomizerPanelProps): JSX.Element {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const inviteUrl = invite?.url ?? '';
|
||||
const defaultInstructions = React.useMemo(() => {
|
||||
const value = t('tasks.customizer.defaults.instructions', { returnObjects: true }) as unknown;
|
||||
return Array.isArray(value) ? (value as string[]) : ['QR-Code scannen', 'Profil anlegen', 'Fotos teilen'];
|
||||
}, [t]);
|
||||
|
||||
const [availableLayouts, setAvailableLayouts] = React.useState<EventQrInviteLayout[]>(invite?.layouts ?? []);
|
||||
const [layoutsLoading, setLayoutsLoading] = React.useState(false);
|
||||
const [layoutsError, setLayoutsError] = React.useState<string | null>(null);
|
||||
const [selectedLayoutId, setSelectedLayoutId] = React.useState<string | undefined>(initialCustomization?.layout_id ?? invite?.layouts?.[0]?.id);
|
||||
const [form, setForm] = React.useState<QrLayoutCustomization>({});
|
||||
const [instructions, setInstructions] = React.useState<string[]>([]);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const formRef = React.useRef<HTMLFormElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!invite) {
|
||||
setAvailableLayouts([]);
|
||||
setSelectedLayoutId(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const layouts = invite.layouts ?? [];
|
||||
setAvailableLayouts(layouts);
|
||||
setLayoutsError(null);
|
||||
setSelectedLayoutId((current) => {
|
||||
if (current && layouts.some((layout) => layout.id === current)) {
|
||||
return current;
|
||||
}
|
||||
if (initialCustomization?.layout_id && layouts.some((layout) => layout.id === initialCustomization.layout_id)) {
|
||||
return initialCustomization.layout_id;
|
||||
}
|
||||
return layouts[0]?.id;
|
||||
});
|
||||
}, [invite?.id, initialCustomization?.layout_id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadLayouts(url: string) {
|
||||
try {
|
||||
setLayoutsLoading(true);
|
||||
setLayoutsError(null);
|
||||
const target = (() => {
|
||||
try {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
const parsed = new URL(url);
|
||||
return parsed.pathname + parsed.search;
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('[Invites] Failed to parse layout URL', parseError);
|
||||
}
|
||||
return url;
|
||||
})();
|
||||
console.debug('[Invites] Fetching layouts', target);
|
||||
const response = await authorizedFetch(target, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[Invites] Layout request failed', response.status, response.statusText);
|
||||
throw new Error(`Failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const items = Array.isArray(json?.data) ? (json.data as EventQrInviteLayout[]) : [];
|
||||
console.debug('[Invites] Layout response items', items);
|
||||
|
||||
if (!cancelled) {
|
||||
setAvailableLayouts(items);
|
||||
setSelectedLayoutId((current) => {
|
||||
if (current && items.some((layout) => layout.id === current)) {
|
||||
return current;
|
||||
}
|
||||
if (initialCustomization?.layout_id && items.some((layout) => layout.id === initialCustomization.layout_id)) {
|
||||
return initialCustomization.layout_id;
|
||||
}
|
||||
return items[0]?.id;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error('[Invites] Failed to load layouts', err);
|
||||
setLayoutsError(t('invites.customizer.loadingError', 'Layouts konnten nicht geladen werden.'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLayoutsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!invite || availableLayouts.length > 0 || !invite.layouts_url) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
void loadLayouts(invite.layouts_url);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [invite, availableLayouts.length, initialCustomization?.layout_id, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!availableLayouts.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedLayoutId((current) => {
|
||||
if (current && availableLayouts.some((layout) => layout.id === current)) {
|
||||
return current;
|
||||
}
|
||||
if (initialCustomization?.layout_id && availableLayouts.some((layout) => layout.id === initialCustomization.layout_id)) {
|
||||
return initialCustomization.layout_id;
|
||||
}
|
||||
return availableLayouts[0].id;
|
||||
});
|
||||
}, [availableLayouts, initialCustomization?.layout_id]);
|
||||
|
||||
const activeLayout = React.useMemo(() => {
|
||||
if (!availableLayouts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedLayoutId) {
|
||||
const match = availableLayouts.find((layout) => layout.id === selectedLayoutId);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return availableLayouts[0];
|
||||
}, [availableLayouts, selectedLayoutId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!invite || !activeLayout) {
|
||||
setForm({});
|
||||
setInstructions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseInstructions = Array.isArray(initialCustomization?.instructions) && initialCustomization.instructions?.length
|
||||
? [...(initialCustomization.instructions as string[])]
|
||||
: [...defaultInstructions];
|
||||
|
||||
setInstructions(baseInstructions);
|
||||
|
||||
setForm({
|
||||
layout_id: activeLayout.id,
|
||||
headline: initialCustomization?.headline ?? eventName,
|
||||
subtitle: initialCustomization?.subtitle ?? activeLayout.subtitle ?? '',
|
||||
description: initialCustomization?.description ?? activeLayout.description ?? '',
|
||||
badge_label: initialCustomization?.badge_label ?? t('tasks.customizer.defaults.badgeLabel'),
|
||||
instructions_heading: initialCustomization?.instructions_heading ?? t('tasks.customizer.defaults.instructionsHeading'),
|
||||
link_heading: initialCustomization?.link_heading ?? t('tasks.customizer.defaults.linkHeading'),
|
||||
link_label: initialCustomization?.link_label ?? inviteUrl,
|
||||
cta_label: initialCustomization?.cta_label ?? t('tasks.customizer.defaults.ctaLabel'),
|
||||
accent_color: initialCustomization?.accent_color ?? activeLayout.preview?.accent ?? '#6366F1',
|
||||
text_color: initialCustomization?.text_color ?? activeLayout.preview?.text ?? '#111827',
|
||||
background_color: initialCustomization?.background_color ?? activeLayout.preview?.background ?? '#FFFFFF',
|
||||
secondary_color: initialCustomization?.secondary_color ?? 'rgba(15,23,42,0.08)',
|
||||
badge_color: initialCustomization?.badge_color ?? activeLayout.preview?.accent ?? '#2563EB',
|
||||
background_gradient: initialCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null,
|
||||
logo_data_url: initialCustomization?.logo_data_url ?? initialCustomization?.logo_url ?? null,
|
||||
});
|
||||
setError(null);
|
||||
}, [invite?.id, activeLayout?.id, defaultInstructions, initialCustomization, eventName, inviteUrl, t]);
|
||||
|
||||
const effectiveInstructions = instructions.filter((entry) => entry.trim().length > 0);
|
||||
|
||||
function updateForm<T extends keyof QrLayoutCustomization>(key: T, value: QrLayoutCustomization[T]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function handleLayoutSelect(layout: EventQrInviteLayout) {
|
||||
setSelectedLayoutId(layout.id);
|
||||
updateForm('layout_id', layout.id);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
accent_color: prev.accent_color ?? layout.preview?.accent ?? '#6366F1',
|
||||
text_color: prev.text_color ?? layout.preview?.text ?? '#111827',
|
||||
background_color: prev.background_color ?? layout.preview?.background ?? '#FFFFFF',
|
||||
background_gradient: prev.background_gradient ?? layout.preview?.background_gradient ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleInstructionChange(index: number, value: string) {
|
||||
setInstructions((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = value;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddInstruction() {
|
||||
setInstructions((prev) => (prev.length < MAX_INSTRUCTIONS ? [...prev, ''] : prev));
|
||||
}
|
||||
|
||||
function handleRemoveInstruction(index: number) {
|
||||
setInstructions((prev) => prev.filter((_, idx) => idx !== index));
|
||||
}
|
||||
|
||||
function handleLogoUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 1024 * 1024) {
|
||||
setError(t('tasks.customizer.errors.logoTooLarge', 'Das Logo darf maximal 1 MB groß sein.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
updateForm('logo_data_url', typeof reader.result === 'string' ? reader.result : null);
|
||||
setError(null);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function handleLogoRemove() {
|
||||
updateForm('logo_data_url', null);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!invite || !activeLayout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: QrLayoutCustomization = {
|
||||
...form,
|
||||
layout_id: activeLayout.id,
|
||||
instructions: effectiveInstructions,
|
||||
};
|
||||
|
||||
await onSave(payload);
|
||||
}
|
||||
|
||||
async function handleResetClick() {
|
||||
await onReset();
|
||||
}
|
||||
|
||||
function handleDownload(format: string, url: string) {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${invite?.token ?? 'invite'}-${format.toLowerCase()}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function handlePrint(preferredUrl?: string | null) {
|
||||
const url = preferredUrl ?? activeLayout?.download_urls?.pdf ?? activeLayout?.download_urls?.a4 ?? null;
|
||||
if (!url) {
|
||||
setError(t('invites.labels.noPrintSource', 'Keine druckbare Version verfügbar.'));
|
||||
return;
|
||||
}
|
||||
const printWindow = window.open(url, '_blank', 'noopener,noreferrer');
|
||||
printWindow?.focus();
|
||||
}
|
||||
|
||||
const previewStyles = React.useMemo(() => {
|
||||
const gradient = form.background_gradient ?? activeLayout?.preview?.background_gradient ?? null;
|
||||
if (gradient?.stops && gradient.stops.length > 0) {
|
||||
const angle = gradient.angle ?? 180;
|
||||
const stops = gradient.stops.join(', ');
|
||||
return { backgroundImage: `linear-gradient(${angle}deg, ${stops})` };
|
||||
}
|
||||
return { backgroundColor: form.background_color ?? activeLayout?.preview?.background ?? '#F8FAFC' };
|
||||
}, [form.background_color, form.background_gradient, activeLayout]);
|
||||
|
||||
if (!invite) {
|
||||
return (
|
||||
<CardPlaceholder
|
||||
title={t('invites.customizer.placeholderTitle', 'Kein Layout verfügbar')}
|
||||
description={t('invites.customizer.placeholderCopy', 'Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!availableLayouts.length) {
|
||||
if (layoutsLoading) {
|
||||
return (
|
||||
<CardPlaceholder
|
||||
title={t('invites.customizer.loadingTitle', 'Layouts werden geladen')}
|
||||
description={t('invites.customizer.loadingDescription', 'Bitte warte einen Moment, wir bereiten die Drucklayouts vor.')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardPlaceholder
|
||||
title={layoutsError ?? t('invites.customizer.placeholderTitle', 'Kein Layout verfügbar')}
|
||||
description={layoutsError ?? t('invites.customizer.placeholderCopy', 'Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeLayout) {
|
||||
return (
|
||||
<CardPlaceholder
|
||||
title={t('invites.customizer.placeholderTitle', 'Kein Layout verfügbar')}
|
||||
description={t('invites.customizer.placeholderCopy', 'Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{t('invites.customizer.heading', 'Layout anpassen')}</h2>
|
||||
<p className="text-sm text-slate-600">{t('invites.customizer.copy', 'Verleihe der Einladung euren Ton: Texte, Farben und Logo lassen sich live bearbeiten.')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={handleResetClick} disabled={resetting || saving}>
|
||||
{resetting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCcw className="mr-2 h-4 w-4" />}
|
||||
{t('invites.customizer.actions.reset', 'Zurücksetzen')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (formRef.current) {
|
||||
if (typeof formRef.current.requestSubmit === 'function') {
|
||||
formRef.current.requestSubmit();
|
||||
} else {
|
||||
formRef.current.submit();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white shadow-lg shadow-rose-500/20"
|
||||
disabled={saving || resetting}
|
||||
>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||||
{t('invites.customizer.actions.save', 'Layout speichern')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]">
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
|
||||
<section className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
|
||||
<header>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.layouts', 'Layouts')}</h3>
|
||||
<p className="text-xs text-slate-500">{t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.')}</p>
|
||||
</header>
|
||||
<div className="flex snap-x gap-3 overflow-x-auto pb-2">
|
||||
{availableLayouts.map((layout) => (
|
||||
<button
|
||||
key={layout.id}
|
||||
type="button"
|
||||
onClick={() => handleLayoutSelect(layout)}
|
||||
className={`min-w-[200px] shrink-0 rounded-xl border p-3 text-left transition-all ${layout.id === selectedLayoutId ? 'border-amber-400 bg-amber-50 shadow' : 'border-slate-200 bg-white hover:border-amber-200'}`}
|
||||
>
|
||||
<div className="text-sm font-semibold text-slate-900">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</div>
|
||||
{layout.description ? <div className="mt-1 text-xs text-slate-500">{layout.description}</div> : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-500">
|
||||
{layout.formats?.map((format) => (
|
||||
<span key={`${layout.id}-${format}`} className="rounded-full border border-amber-200 px-2 py-0.5 text-amber-600">{String(format).toUpperCase()}</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
|
||||
<header>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.text', 'Texte')}</h3>
|
||||
</header>
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-headline">{t('invites.customizer.fields.headline', 'Überschrift')}</Label>
|
||||
<Input
|
||||
id="invite-headline"
|
||||
value={form.headline ?? ''}
|
||||
onChange={(event) => updateForm('headline', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-subtitle">{t('invites.customizer.fields.subtitle', 'Unterzeile')}</Label>
|
||||
<Input
|
||||
id="invite-subtitle"
|
||||
value={form.subtitle ?? ''}
|
||||
onChange={(event) => updateForm('subtitle', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-description">{t('invites.customizer.fields.description', 'Beschreibung')}</Label>
|
||||
<Textarea
|
||||
id="invite-description"
|
||||
value={form.description ?? ''}
|
||||
onChange={(event) => updateForm('description', event.target.value)}
|
||||
className="min-h-[96px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-badge">{t('invites.customizer.fields.badge', 'Badge-Label')}</Label>
|
||||
<Input
|
||||
id="invite-badge"
|
||||
value={form.badge_label ?? ''}
|
||||
onChange={(event) => updateForm('badge_label', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-cta">{t('invites.customizer.fields.cta', 'Call-to-Action')}</Label>
|
||||
<Input
|
||||
id="invite-cta"
|
||||
value={form.cta_label ?? ''}
|
||||
onChange={(event) => updateForm('cta_label', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-link-heading">{t('invites.customizer.fields.linkHeading', 'Link-Überschrift')}</Label>
|
||||
<Input
|
||||
id="invite-link-heading"
|
||||
value={form.link_heading ?? ''}
|
||||
onChange={(event) => updateForm('link_heading', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-link-label">{t('invites.customizer.fields.linkLabel', 'Link/Begleittext')}</Label>
|
||||
<Input
|
||||
id="invite-link-label"
|
||||
value={form.link_label ?? ''}
|
||||
onChange={(event) => updateForm('link_label', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
|
||||
<header>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.instructions', 'Schritt-für-Schritt')}</h3>
|
||||
<p className="text-xs text-slate-500">{t('invites.customizer.sections.instructionsHint', 'Helft euren Gästen mit klaren Aufgaben. Maximal fünf Punkte.')}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddInstruction} disabled={instructions.length >= MAX_INSTRUCTIONS}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t('invites.customizer.actions.addInstruction', 'Punkt hinzufügen')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-instruction-heading">{t('invites.customizer.fields.instructionsHeading', 'Abschnittsüberschrift')}</Label>
|
||||
<Input
|
||||
id="invite-instruction-heading"
|
||||
value={form.instructions_heading ?? ''}
|
||||
onChange={(event) => updateForm('instructions_heading', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{instructions.map((entry, index) => (
|
||||
<div key={`instruction-${index}`} className="flex gap-2">
|
||||
<Input
|
||||
value={entry}
|
||||
onChange={(event) => handleInstructionChange(index, event.target.value)}
|
||||
placeholder={t('invites.customizer.fields.instructionPlaceholder', 'Beschreibung des Schritts')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-slate-500 hover:text-rose-500"
|
||||
onClick={() => handleRemoveInstruction(index)}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
|
||||
<header>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.branding', 'Branding')}</h3>
|
||||
</header>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-accent">{t('invites.customizer.fields.accentColor', 'Akzentfarbe')}</Label>
|
||||
<Input
|
||||
id="invite-accent"
|
||||
type="color"
|
||||
value={form.accent_color ?? '#6366F1'}
|
||||
onChange={(event) => updateForm('accent_color', event.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-text-color">{t('invites.customizer.fields.textColor', 'Textfarbe')}</Label>
|
||||
<Input
|
||||
id="invite-text-color"
|
||||
type="color"
|
||||
value={form.text_color ?? '#111827'}
|
||||
onChange={(event) => updateForm('text_color', event.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-background-color">{t('invites.customizer.fields.backgroundColor', 'Hintergrund')}</Label>
|
||||
<Input
|
||||
id="invite-background-color"
|
||||
type="color"
|
||||
value={form.background_color ?? '#FFFFFF'}
|
||||
onChange={(event) => updateForm('background_color', event.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-badge-color">{t('invites.customizer.fields.badgeColor', 'Badge')}</Label>
|
||||
<Input
|
||||
id="invite-badge-color"
|
||||
type="color"
|
||||
value={form.badge_color ?? '#2563EB'}
|
||||
onChange={(event) => updateForm('badge_color', event.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('invites.customizer.fields.logo', 'Logo')}</Label>
|
||||
{form.logo_data_url ? (
|
||||
<div className="flex items-center gap-4 rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<img src={form.logo_data_url} alt="Logo" className="h-12 w-12 rounded border border-slate-200 object-contain" />
|
||||
<Button type="button" variant="ghost" onClick={handleLogoRemove} className="text-rose-500 hover:text-rose-600">
|
||||
{t('invites.customizer.actions.removeLogo', 'Logo entfernen')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-lg border border-dashed border-slate-300 bg-slate-50/80 px-4 py-3 text-sm text-slate-500 hover:border-amber-200">
|
||||
<UploadCloud className="h-4 w-4" />
|
||||
<span>{t('invites.customizer.actions.uploadLogo', 'Logo hochladen (max. 1 MB)')}</span>
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleLogoUpload} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleResetClick} disabled={resetting || saving}>
|
||||
{resetting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCcw className="mr-2 h-4 w-4" />}
|
||||
{t('invites.customizer.actions.reset', 'Zurücksetzen')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving || resetting} className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||||
{t('invites.customizer.actions.save', 'Layout speichern')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<aside className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.preview.title', 'Live-Vorschau')}</h3>
|
||||
<p className="text-xs text-slate-500">{t('invites.customizer.preview.subtitle', 'So sieht dein Layout beim Export aus.')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => handlePrint(activeLayout.download_urls?.pdf ?? activeLayout.download_urls?.a4)}>
|
||||
<Printer className="mr-1 h-4 w-4" />
|
||||
{t('invites.customizer.actions.print', 'Drucken')}
|
||||
</Button>
|
||||
{activeLayout.formats?.map((format) => {
|
||||
const key = String(format ?? '').toLowerCase();
|
||||
const url = activeLayout.download_urls?.[key];
|
||||
if (!url) return null;
|
||||
return (
|
||||
<Button key={`${activeLayout.id}-${key}`} type="button" variant="outline" size="sm" onClick={() => handleDownload(key, url)}>
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
{key.toUpperCase()}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border border-slate-200 bg-white p-5 shadow-inner">
|
||||
<div className="rounded-xl p-5 text-slate-900" style={previewStyles}>
|
||||
<div className="flex items-start justify-between">
|
||||
<span
|
||||
className="rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide"
|
||||
style={{ backgroundColor: form.badge_color ?? form.accent_color ?? '#2563EB', color: '#ffffff' }}
|
||||
>
|
||||
{form.badge_label || t('tasks.customizer.defaults.badgeLabel')}
|
||||
</span>
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-white/80 shadow" style={{ color: form.accent_color ?? '#2563EB' }}>
|
||||
<SmileIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<h4 className="text-lg font-semibold leading-tight" style={{ color: form.text_color ?? '#111827' }}>
|
||||
{form.headline || eventName}
|
||||
</h4>
|
||||
{form.subtitle ? (
|
||||
<p className="text-sm" style={{ color: form.text_color ?? '#111827', opacity: 0.75 }}>
|
||||
{form.subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{form.description ? (
|
||||
<p className="mt-4 text-sm" style={{ color: form.text_color ?? '#111827' }}>
|
||||
{form.description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-5 grid gap-3 rounded-xl bg-white/80 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">{form.instructions_heading}</div>
|
||||
<ol className="grid gap-2 text-sm text-slate-700">
|
||||
{effectiveInstructions.slice(0, 4).map((item, index) => (
|
||||
<li key={`preview-instruction-${index}`} className="flex gap-2">
|
||||
<span className="font-semibold" style={{ color: form.accent_color ?? '#2563EB' }}>{index + 1}.</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col gap-2 rounded-xl bg-white/80 p-4 text-sm text-slate-700">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">{form.link_heading}</span>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-medium" style={{ color: form.accent_color ?? '#2563EB' }}>
|
||||
{form.link_label || inviteUrl}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-900/90 px-3 py-1 text-xs text-white">QR</span>
|
||||
</div>
|
||||
<Button size="sm" className="w-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
|
||||
{form.cta_label || t('tasks.customizer.defaults.ctaLabel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{form.logo_data_url ? (
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed border-slate-200 bg-slate-50 py-4">
|
||||
<img src={form.logo_data_url} alt="Logo preview" className="max-h-16 object-contain" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardPlaceholder({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-10 text-center text-sm text-slate-500">
|
||||
<h3 className="text-base font-semibold text-slate-700">{title}</h3>
|
||||
<p className="mt-2 text-sm text-slate-500">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SmileIcon(): JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-5 w-5">
|
||||
<circle cx="12" cy="12" r="10" opacity="0.4" />
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
||||
<path d="M9 9h.01M15 9h.01" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,514 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
import type { EventQrInviteLayout } from '../../api';
|
||||
|
||||
export type QrLayoutCustomization = {
|
||||
layout_id?: string;
|
||||
headline?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
badge_label?: string;
|
||||
instructions_heading?: string;
|
||||
instructions?: string[];
|
||||
link_heading?: string;
|
||||
link_label?: string;
|
||||
cta_label?: string;
|
||||
accent_color?: string;
|
||||
text_color?: string;
|
||||
background_color?: string;
|
||||
secondary_color?: string;
|
||||
badge_color?: string;
|
||||
background_gradient?: { angle?: number; stops?: string[] } | null;
|
||||
logo_data_url?: string | null;
|
||||
logo_url?: string | null;
|
||||
};
|
||||
|
||||
const MAX_INSTRUCTIONS = 5;
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (customization: QrLayoutCustomization) => Promise<void>;
|
||||
onReset: () => Promise<void>;
|
||||
saving: boolean;
|
||||
inviteUrl: string;
|
||||
eventName: string;
|
||||
layouts: EventQrInviteLayout[];
|
||||
initialCustomization: QrLayoutCustomization | null;
|
||||
};
|
||||
|
||||
export function QrInviteCustomizationDialog({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onReset,
|
||||
saving,
|
||||
inviteUrl,
|
||||
eventName,
|
||||
layouts,
|
||||
initialCustomization,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('management');
|
||||
const [selectedLayoutId, setSelectedLayoutId] = React.useState<string | undefined>();
|
||||
const [form, setForm] = React.useState<QrLayoutCustomization>({});
|
||||
const [instructions, setInstructions] = React.useState<string[]>([]);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const defaultInstructions = React.useMemo(() => {
|
||||
const value = t('tasks.customizer.defaults.instructions', { returnObjects: true }) as unknown;
|
||||
return Array.isArray(value) ? (value as string[]) : ['QR-Code scannen', 'Profil anlegen', 'Fotos teilen'];
|
||||
}, [t]);
|
||||
|
||||
const selectedLayout = React.useMemo(() => {
|
||||
if (layouts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const fallback = layouts[0];
|
||||
if (!selectedLayoutId) {
|
||||
return fallback;
|
||||
}
|
||||
return layouts.find((layout) => layout.id === selectedLayoutId) ?? fallback;
|
||||
}, [layouts, selectedLayoutId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultLayout = initialCustomization?.layout_id
|
||||
? layouts.find((layout) => layout.id === initialCustomization.layout_id)
|
||||
: undefined;
|
||||
|
||||
const layout = defaultLayout ?? layouts[0];
|
||||
setSelectedLayoutId(layout?.id);
|
||||
|
||||
const nextInstructions = Array.isArray(initialCustomization?.instructions)
|
||||
? initialCustomization!.instructions!
|
||||
: [];
|
||||
|
||||
setInstructions(nextInstructions.length > 0 ? nextInstructions : defaultInstructions);
|
||||
|
||||
setForm({
|
||||
layout_id: layout?.id,
|
||||
headline: initialCustomization?.headline ?? eventName,
|
||||
subtitle: initialCustomization?.subtitle ?? layout?.subtitle ?? '',
|
||||
description: initialCustomization?.description ?? layout?.description ?? '',
|
||||
badge_label: initialCustomization?.badge_label ?? t('tasks.customizer.defaults.badgeLabel'),
|
||||
instructions_heading:
|
||||
initialCustomization?.instructions_heading ?? t("tasks.customizer.defaults.instructionsHeading"),
|
||||
link_heading: initialCustomization?.link_heading ?? t('tasks.customizer.defaults.linkHeading'),
|
||||
link_label: initialCustomization?.link_label ?? inviteUrl,
|
||||
cta_label: initialCustomization?.cta_label ?? t('tasks.customizer.defaults.ctaLabel'),
|
||||
accent_color: initialCustomization?.accent_color ?? layout?.preview?.accent ?? '#6366F1',
|
||||
text_color: initialCustomization?.text_color ?? layout?.preview?.text ?? '#111827',
|
||||
background_color: initialCustomization?.background_color ?? layout?.preview?.background ?? '#FFFFFF',
|
||||
secondary_color: initialCustomization?.secondary_color ?? 'rgba(15,23,42,0.08)',
|
||||
badge_color: initialCustomization?.badge_color ?? layout?.preview?.accent ?? '#2563EB',
|
||||
background_gradient: initialCustomization?.background_gradient ?? layout?.preview?.background_gradient ?? null,
|
||||
logo_data_url: initialCustomization?.logo_data_url ?? initialCustomization?.logo_url ?? null,
|
||||
});
|
||||
setError(null);
|
||||
}, [open, layouts, initialCustomization, inviteUrl, eventName, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedLayout) {
|
||||
return;
|
||||
}
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
layout_id: selectedLayout.id,
|
||||
accent_color: prev.accent_color ?? selectedLayout.preview?.accent ?? '#6366F1',
|
||||
text_color: prev.text_color ?? selectedLayout.preview?.text ?? '#111827',
|
||||
background_color: prev.background_color ?? selectedLayout.preview?.background ?? '#FFFFFF',
|
||||
background_gradient: prev.background_gradient ?? selectedLayout.preview?.background_gradient ?? null,
|
||||
}));
|
||||
}, [selectedLayout]);
|
||||
|
||||
const handleColorChange = (key: keyof QrLayoutCustomization) =>
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm((prev) => ({ ...prev, [key]: event.target.value }));
|
||||
};
|
||||
|
||||
const handleInputChange = (key: keyof QrLayoutCustomization) =>
|
||||
(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setForm((prev) => ({ ...prev, [key]: event.target.value }));
|
||||
};
|
||||
|
||||
const handleInstructionChange = (index: number, value: string) => {
|
||||
setInstructions((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = value;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddInstruction = () => {
|
||||
setInstructions((prev) => (prev.length < MAX_INSTRUCTIONS ? [...prev, ''] : prev));
|
||||
};
|
||||
|
||||
const handleRemoveInstruction = (index: number) => {
|
||||
setInstructions((prev) => prev.filter((_, idx) => idx !== index));
|
||||
};
|
||||
|
||||
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 1024 * 1024) {
|
||||
setError(t('tasks.customizer.errors.logoTooLarge', 'Das Logo darf maximal 1 MB groß sein.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setForm((prev) => ({ ...prev, logo_data_url: typeof reader.result === 'string' ? reader.result : null }));
|
||||
setError(null);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleLogoRemove = () => {
|
||||
setForm((prev) => ({ ...prev, logo_data_url: null }));
|
||||
};
|
||||
|
||||
const effectiveInstructions = instructions.filter((entry) => entry.trim().length > 0);
|
||||
|
||||
const preview = React.useMemo(() => {
|
||||
const backgroundStyle = form.background_gradient?.stops && form.background_gradient.stops.length > 0
|
||||
? `linear-gradient(${form.background_gradient.angle ?? 180}deg, ${form.background_gradient.stops.join(',')})`
|
||||
: form.background_color ?? selectedLayout?.preview?.background ?? '#FFFFFF';
|
||||
|
||||
return {
|
||||
background: backgroundStyle,
|
||||
accent: form.accent_color ?? selectedLayout?.preview?.accent ?? '#6366F1',
|
||||
text: form.text_color ?? selectedLayout?.preview?.text ?? '#111827',
|
||||
secondary: form.secondary_color ?? 'rgba(15,23,42,0.08)',
|
||||
};
|
||||
}, [form, selectedLayout]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!selectedLayout) {
|
||||
setError(t('tasks.customizer.errors.noLayout', 'Bitte wähle ein Layout aus.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
await onSubmit({
|
||||
...form,
|
||||
layout_id: selectedLayout.id,
|
||||
instructions: effectiveInstructions,
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setError(null);
|
||||
await onReset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('tasks.customizer.title', 'QR-Einladung anpassen')}</DialogTitle>
|
||||
<DialogDescription>{t('tasks.customizer.description', 'Passe Layout, Texte und Farben deiner QR-Einladung an.')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="grid gap-6 md:grid-cols-[2fr,1fr]">
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="qr-layout">{t('tasks.customizer.layout', 'Layout')}</Label>
|
||||
<Select
|
||||
value={selectedLayout?.id}
|
||||
onValueChange={(value) => setSelectedLayoutId(value)}
|
||||
disabled={layouts.length === 0 || saving}
|
||||
>
|
||||
<SelectTrigger id="qr-layout">
|
||||
<SelectValue placeholder={t('tasks.customizer.selectLayout', 'Layout auswählen')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{layouts.map((layout) => (
|
||||
<SelectItem key={layout.id} value={layout.id}>
|
||||
{layout.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="headline">{t('tasks.customizer.headline', 'Überschrift')}</Label>
|
||||
<Input
|
||||
id="headline"
|
||||
value={form.headline ?? ''}
|
||||
onChange={handleInputChange('headline')}
|
||||
maxLength={120}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subtitle">{t('tasks.customizer.subtitle', 'Unterzeile')}</Label>
|
||||
<Input
|
||||
id="subtitle"
|
||||
value={form.subtitle ?? ''}
|
||||
onChange={handleInputChange('subtitle')}
|
||||
maxLength={160}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="badgeLabel">{t('tasks.customizer.badgeLabel', 'Badge')}</Label>
|
||||
<Input
|
||||
id="badgeLabel"
|
||||
value={form.badge_label ?? ''}
|
||||
onChange={handleInputChange('badge_label')}
|
||||
maxLength={80}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">{t('tasks.customizer.descriptionLabel', 'Beschreibung')}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
rows={3}
|
||||
value={form.description ?? ''}
|
||||
onChange={handleInputChange('description')}
|
||||
maxLength={500}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="instructionsHeading">{t('tasks.customizer.instructionsHeading', "Anleitungstitel")}</Label>
|
||||
<Input
|
||||
id="instructionsHeading"
|
||||
value={form.instructions_heading ?? ''}
|
||||
onChange={handleInputChange('instructions_heading')}
|
||||
maxLength={120}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ctaLabel">{t('tasks.customizer.ctaLabel', 'CTA')}</Label>
|
||||
<Input
|
||||
id="ctaLabel"
|
||||
value={form.cta_label ?? ''}
|
||||
onChange={handleInputChange('cta_label')}
|
||||
maxLength={120}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="linkHeading">{t('tasks.customizer.linkHeading', 'Link-Titel')}</Label>
|
||||
<Input
|
||||
id="linkHeading"
|
||||
value={form.link_heading ?? ''}
|
||||
onChange={handleInputChange('link_heading')}
|
||||
maxLength={120}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="linkLabel">{t('tasks.customizer.linkLabel', 'Link')}</Label>
|
||||
<Input
|
||||
id="linkLabel"
|
||||
value={form.link_label ?? ''}
|
||||
onChange={handleInputChange('link_label')}
|
||||
maxLength={160}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('tasks.customizer.instructionsLabel', 'Hinweise')}</Label>
|
||||
<div className="space-y-2">
|
||||
{instructions.map((instruction, index) => (
|
||||
<div key={`instruction-${index}`} className="flex items-start gap-2">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={instruction}
|
||||
onChange={(event) => handleInstructionChange(index, event.target.value)}
|
||||
maxLength={160}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={() => handleRemoveInstruction(index)} disabled={saving}>
|
||||
{t('tasks.customizer.removeInstruction', 'Entfernen')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAddInstruction}
|
||||
disabled={instructions.length >= MAX_INSTRUCTIONS || saving}
|
||||
>
|
||||
{t('tasks.customizer.addInstruction', 'Hinweis hinzufügen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<ColorField
|
||||
label={t('tasks.customizer.colors.accent', 'Akzentfarbe')}
|
||||
value={form.accent_color ?? '#6366F1'}
|
||||
onChange={handleColorChange('accent_color')}
|
||||
disabled={saving}
|
||||
/>
|
||||
<ColorField
|
||||
label={t('tasks.customizer.colors.text', 'Textfarbe')}
|
||||
value={form.text_color ?? '#111827'}
|
||||
onChange={handleColorChange('text_color')}
|
||||
disabled={saving}
|
||||
/>
|
||||
<ColorField
|
||||
label={t('tasks.customizer.colors.background', 'Hintergrund')}
|
||||
value={form.background_color ?? '#FFFFFF'}
|
||||
onChange={handleColorChange('background_color')}
|
||||
disabled={saving}
|
||||
/>
|
||||
<ColorField
|
||||
label={t('tasks.customizer.colors.secondary', 'Sekundärfarbe')}
|
||||
value={form.secondary_color ?? '#CBD5F5'}
|
||||
onChange={handleColorChange('secondary_color')}
|
||||
disabled={saving}
|
||||
/>
|
||||
<ColorField
|
||||
label={t('tasks.customizer.colors.badge', 'Badge-Farbe')}
|
||||
value={form.badge_color ?? '#2563EB'}
|
||||
onChange={handleColorChange('badge_color')}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('tasks.customizer.logo.label', 'Logo')}</Label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input type="file" accept="image/png,image/jpeg,image/svg+xml" onChange={handleLogoUpload} disabled={saving} />
|
||||
{form.logo_data_url ? (
|
||||
<Button type="button" variant="outline" onClick={handleLogoRemove} disabled={saving}>
|
||||
{t('tasks.customizer.logo.remove', 'Logo entfernen')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('tasks.customizer.logo.hint', 'PNG oder SVG, max. 1 MB. Wird oben rechts platziert.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<h4 className="text-sm font-semibold text-slate-900">
|
||||
{t('tasks.customizer.preview.title', 'Vorschau')}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-600">
|
||||
{t('tasks.customizer.preview.hint', 'Farben und Texte, wie sie im Layout erscheinen. Speichere, um neue PDFs/SVGs zu erhalten.')}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="space-y-3 rounded-3xl border border-slate-200 p-4 text-xs text-slate-700 shadow-sm"
|
||||
style={{
|
||||
background: preview.background,
|
||||
color: preview.text,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full bg-[var(--badge-color,#1f2937)] px-3 py-1 text-[10px] font-semibold uppercase tracking-wide"
|
||||
style={{ background: form.badge_color ?? preview.accent }}
|
||||
>
|
||||
{form.badge_label ?? t('tasks.customizer.defaults.badgeLabel')}
|
||||
</span>
|
||||
{form.logo_data_url ? (
|
||||
<img src={form.logo_data_url} alt="Logo" className="h-12 w-auto object-contain" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-semibold">{form.headline ?? eventName}</p>
|
||||
{form.subtitle ? <p className="text-sm opacity-80">{form.subtitle}</p> : null}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: preview.accent }}>
|
||||
{form.instructions_heading ?? t('tasks.customizer.defaults.instructionsHeading')}
|
||||
</p>
|
||||
<ul className="space-y-1 text-xs">
|
||||
{(effectiveInstructions.length > 0 ? effectiveInstructions : defaultInstructions).map((item, index) => (
|
||||
<li key={`preview-instruction-${index}`}>• {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: preview.accent }}>
|
||||
{form.link_heading ?? t('tasks.customizer.defaults.linkHeading')}
|
||||
</p>
|
||||
<div className="rounded-lg border border-white/40 bg-white/80 p-2 text-[11px]" style={{ color: preview.text }}>
|
||||
{form.link_label ?? inviteUrl}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: preview.accent }}>
|
||||
{form.cta_label ?? t('tasks.customizer.defaults.ctaLabel')}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<input type="hidden" value={form.layout_id ?? ''} />
|
||||
|
||||
<DialogFooter className="md:col-span-2">
|
||||
<div className="flex flex-1 flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" onClick={handleReset} disabled={saving}>
|
||||
{t('tasks.customizer.actions.reset', 'Zurücksetzen')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
|
||||
{t('tasks.customizer.actions.cancel', 'Abbrechen')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{error ? <span className="text-sm text-destructive">{error}</span> : null}
|
||||
<Button type="submit" disabled={saving}>
|
||||
{t('tasks.customizer.actions.save', 'Speichern')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{label}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input type="color" value={value} onChange={onChange} disabled={disabled} className="h-10 w-14 p-1" />
|
||||
<Input value={value} onChange={onChange} disabled={disabled} pattern="^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QrInviteCustomizationDialog;
|
||||
@@ -10,6 +10,8 @@ import EventDetailPage from './pages/EventDetailPage';
|
||||
import EventMembersPage from './pages/EventMembersPage';
|
||||
import EventTasksPage from './pages/EventTasksPage';
|
||||
import EventToolkitPage from './pages/EventToolkitPage';
|
||||
import EventInvitesPage from './pages/EventInvitesPage';
|
||||
import EngagementPage from './pages/EngagementPage';
|
||||
import BillingPage from './pages/BillingPage';
|
||||
import TasksPage from './pages/TasksPage';
|
||||
import TaskCollectionsPage from './pages/TaskCollectionsPage';
|
||||
@@ -86,7 +88,9 @@ export const router = createBrowserRouter([
|
||||
{ path: 'events/:slug/photos', element: <EventPhotosPage /> },
|
||||
{ path: 'events/:slug/members', element: <EventMembersPage /> },
|
||||
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
|
||||
{ path: 'events/:slug/invites', element: <EventInvitesPage /> },
|
||||
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
|
||||
{ path: 'engagement', element: <EngagementPage /> },
|
||||
{ path: 'tasks', element: <TasksPage /> },
|
||||
{ path: 'task-collections', element: <TaskCollectionsPage /> },
|
||||
{ path: 'emotions', element: <EmotionsPage /> },
|
||||
|
||||
27
resources/js/components/ui/textarea.tsx
Normal file
27
resources/js/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input selection:bg-primary selection:text-primary-foreground flex min-h-[120px] w-full rounded-md border bg-transparent px-3 py-2 text-sm text-foreground shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -90,10 +89,10 @@ function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Badges</CardTitle>
|
||||
<CardDescription>Erfuelle Aufgaben und sammle Likes, um Badges freizuschalten.</CardDescription>
|
||||
<CardDescription>Erfülle Aufgaben und sammle Likes, um Badges freizuschalten.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Noch keine Badges verfuegbar.</p>
|
||||
<p className="text-sm text-muted-foreground">Noch keine Badges verfügbar.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -103,7 +102,7 @@ function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Badges</CardTitle>
|
||||
<CardDescription>Dein Fortschritt bei den verfuegbaren Erfolgen.</CardDescription>
|
||||
<CardDescription>Dein Fortschritt bei den verfügbaren Erfolgen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{badges.map((badge) => (
|
||||
@@ -139,7 +138,7 @@ function Timeline({ points }: { points: TimelinePoint[] }) {
|
||||
{points.map((point) => (
|
||||
<div key={point.date} className="flex items-center justify-between rounded-lg border border-border/40 bg-muted/20 px-3 py-2">
|
||||
<span className="font-medium text-foreground">{point.date}</span>
|
||||
<span className="text-muted-foreground">{point.photos} Fotos | {point.guests} Gaeste</span>
|
||||
<span className="text-muted-foreground">{point.photos} Fotos | {point.guests} Gäste</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
@@ -156,7 +155,7 @@ function Feed({ feed }: { feed: FeedEntry[] }) {
|
||||
<CardDescription>Neue Uploads erscheinen hier in Echtzeit.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Noch keine Uploads - starte die Kamera und lege los!</p>
|
||||
<p className="text-sm text-muted-foreground">Noch keine Uploads – starte die Kamera und lege los!</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -215,7 +214,7 @@ function Highlights({ topPhoto, trendingEmotion }: { topPhoto: TopPhotoHighlight
|
||||
<div className="flex h-48 w-full items-center justify-center bg-muted text-muted-foreground">Kein Vorschau-Bild</div>
|
||||
)}
|
||||
</div>
|
||||
<p><span className="font-semibold text-foreground">{topPhoto.guest || 'Gast'}</span> <EFBFBD> {topPhoto.likes} Likes</p>
|
||||
<p><span className="font-semibold text-foreground">{topPhoto.guest || 'Gast'}</span> – {topPhoto.likes} Likes</p>
|
||||
{topPhoto.task && <p className="text-muted-foreground">Aufgabe: {topPhoto.task}</p>}
|
||||
<p className="text-xs text-muted-foreground">{formatRelativeTime(topPhoto.createdAt)}</p>
|
||||
</CardContent>
|
||||
@@ -252,13 +251,13 @@ function SummaryCards({ data }: { data: AchievementsPayload }) {
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-1 py-4">
|
||||
<span className="text-xs uppercase text-muted-foreground">Aktive Gaeste</span>
|
||||
<span className="text-xs uppercase text-muted-foreground">Aktive Gäste</span>
|
||||
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.uniqueGuests)}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-1 py-4">
|
||||
<span className="text-xs uppercase text-muted-foreground">Erfuellte Aufgaben</span>
|
||||
<span className="text-xs uppercase text-muted-foreground">Erfüllte Aufgaben</span>
|
||||
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.tasksSolved)}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -339,7 +338,7 @@ export default function AchievementsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Erfolge</h1>
|
||||
<p className="text-sm text-muted-foreground">Behalte deine Highlights, Badges und die aktivsten Gaeste im Blick.</p>
|
||||
<p className="text-sm text-muted-foreground">Behalte deine Highlights, Badges und die aktivsten Gäste im Blick.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -424,13 +423,13 @@ export default function AchievementsPage() {
|
||||
title="Top Uploads"
|
||||
icon={Users}
|
||||
entries={data.leaderboards.uploads}
|
||||
emptyCopy="Noch keine Uploads - sobald Fotos vorhanden sind, erscheinen sie hier."
|
||||
emptyCopy="Noch keine Uploads – sobald Fotos vorhanden sind, erscheinen sie hier."
|
||||
/>
|
||||
<Leaderboard
|
||||
title="Beliebteste Gaeste"
|
||||
title="Beliebteste Gäste"
|
||||
icon={Trophy}
|
||||
entries={data.leaderboards.likes}
|
||||
emptyCopy="Likes fehlen noch - motiviere die Gaeste, Fotos zu liken."
|
||||
emptyCopy="Likes fehlen noch – motiviere die Gäste, Fotos zu liken."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,12 +92,12 @@ export default function TaskPickerPage() {
|
||||
map.set(task.emotion.slug, task.emotion.name);
|
||||
}
|
||||
});
|
||||
return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: tokenValue, name }));
|
||||
return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: slugValue, name }));
|
||||
}, [tasks]);
|
||||
|
||||
const filteredTasks = React.useMemo(() => {
|
||||
if (selectedEmotion === 'all') return tasks;
|
||||
return tasks.filter((task) => task.emotion?.token === selectedEmotion);
|
||||
return tasks.filter((task) => task.emotion?.slug === selectedEmotion);
|
||||
}, [tasks, selectedEmotion]);
|
||||
|
||||
const selectRandomTask = React.useCallback(
|
||||
@@ -243,14 +243,14 @@ export default function TaskPickerPage() {
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Aufgabe auswaehlen</h1>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Aufgabe auswählen</h1>
|
||||
<Badge variant="secondary" className="whitespace-nowrap">
|
||||
Schon {completedCount} Aufgaben erledigt
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/40 p-4">
|
||||
<div className="flex items-center justify-between text-sm font-medium text-muted-foreground">
|
||||
<span>Auf dem Weg zum naechsten Erfolg</span>
|
||||
<span>Auf dem Weg zum nächsten Erfolg</span>
|
||||
<span>
|
||||
{completedCount >= TASK_PROGRESS_TARGET
|
||||
? 'Stark!'
|
||||
@@ -427,7 +427,7 @@ export default function TaskPickerPage() {
|
||||
|
||||
{!loading && !tasks.length && !error && (
|
||||
<Alert>
|
||||
<AlertDescription>Fuer dieses Event sind derzeit keine Aufgaben hinterlegt.</AlertDescription>
|
||||
<AlertDescription>Für dieses Event sind derzeit keine Aufgaben hinterlegt.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
@@ -504,8 +504,8 @@ function EmptyState({
|
||||
<h2 className="text-xl font-semibold">Keine passende Aufgabe gefunden</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hasTasks
|
||||
? 'Fuer deine aktuelle Stimmung gibt es gerade keine Aufgabe. Waehle eine andere Stimmung oder lade neue Aufgaben.'
|
||||
: 'Hier sind noch keine Aufgaben hinterlegt. Bitte versuche es spaeter erneut.'}
|
||||
? 'Für deine aktuelle Stimmung gibt es gerade keine Aufgabe. Wähle eine andere Stimmung oder lade neue Aufgaben.'
|
||||
: 'Hier sind noch keine Aufgaben hinterlegt. Bitte versuche es später erneut.'}
|
||||
</p>
|
||||
</div>
|
||||
{hasTasks && emotionOptions.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user