rework of the event admin UI

This commit is contained in:
Codex Agent
2025-11-24 17:17:39 +01:00
parent 4667ec8073
commit 8947a37261
37 changed files with 4381 additions and 874 deletions

View File

@@ -1,7 +1,8 @@
// @ts-nocheck
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart } from 'lucide-react';
import { AlertTriangle, ArrowLeft, CheckCircle2, Circle, Copy, Download, ExternalLink, Link2, Loader2, Mail, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -33,6 +34,8 @@ import {
ADMIN_EVENT_PHOTOS_PATH,
} from '../constants';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { buildEventTabs } from '../lib/eventTabs';
import { getApiErrorMessage } from '../lib/apiError';
import { AddonsPicker } from '../components/Addons/AddonsPicker';
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
@@ -63,7 +66,17 @@ interface PageState {
error: string | null;
}
type TabKey = 'layout' | 'export' | 'links';
type TabKey = 'layout' | 'share' | 'export';
function resolveTabKey(value: string | null): TabKey {
if (value === 'export') {
return 'export';
}
if (value === 'share' || value === 'links') {
return 'share';
}
return 'layout';
}
const HEX_COLOR_FULL = /^#([0-9A-Fa-f]{6})$/;
const HEX_COLOR_SHORT = /^#([0-9A-Fa-f]{3})$/;
@@ -180,7 +193,7 @@ export default function EventInvitesPage(): React.ReactElement {
const [customizerDraft, setCustomizerDraft] = React.useState<QrLayoutCustomization | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const initialTab = tabParam === 'export' || tabParam === 'links' ? (tabParam as TabKey) : 'layout';
const initialTab = resolveTabKey(tabParam);
const [activeTab, setActiveTab] = React.useState<TabKey>(initialTab);
const [exportDownloadBusy, setExportDownloadBusy] = React.useState<string | null>(null);
const [exportPrintBusy, setExportPrintBusy] = React.useState<string | null>(null);
@@ -244,20 +257,19 @@ export default function EventInvitesPage(): React.ReactElement {
}, [recomputeExportScale]);
React.useEffect(() => {
const param = searchParams.get('tab');
const nextTab = param === 'export' || param === 'links' ? (param as TabKey) : 'layout';
const nextTab = resolveTabKey(searchParams.get('tab'));
setActiveTab((current) => (current === nextTab ? current : nextTab));
}, [searchParams]);
const handleTabChange = React.useCallback(
(value: string) => {
const nextTab = value === 'export' || value === 'links' ? (value as TabKey) : 'layout';
const nextTab = resolveTabKey(value);
setActiveTab(nextTab);
const nextParams = new URLSearchParams(searchParams);
if (nextTab === 'layout') {
nextParams.delete('tab');
} else {
nextParams.set('tab', nextTab);
nextParams.set('tab', nextTab === 'share' ? 'share' : 'export');
}
setSearchParams(nextParams, { replace: true });
},
@@ -267,6 +279,17 @@ export default function EventInvitesPage(): React.ReactElement {
const event = state.event;
const eventName = event ? renderEventName(event.name) : t('toolkit.titleFallback', 'Event');
const eventDate = event?.event_date ?? null;
const eventTabs = React.useMemo(() => {
if (!event || !slug) {
return [];
}
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return buildEventTabs(event, translateMenu, {
invites: state.invites.length,
photos: event.photo_count ?? event.pending_photo_count ?? undefined,
tasks: event.tasks_count ?? undefined,
});
}, [event, slug, state.invites.length, t]);
const selectedInvite = React.useMemo(
() => state.invites.find((invite) => invite.id === selectedInviteId) ?? null,
@@ -472,6 +495,39 @@ export default function EventInvitesPage(): React.ReactElement {
return { active, total };
}, [state.invites]);
const primaryInvite = React.useMemo(() => selectedInvite ?? state.invites[0] ?? null, [selectedInvite, state.invites]);
const workflowSteps = React.useMemo<InviteWorkflowStep[]>(() => {
const layoutReady = Boolean(effectiveCustomization);
const shareReady = state.invites.length > 0;
const exportReady = Boolean(exportPreview && exportElements.length);
const mapStatus = (tab: TabKey, done: boolean) => {
if (done) return 'done';
if (activeTab === tab) return 'active';
return 'pending';
};
return [
{
key: 'layout',
title: t('invites.workflow.steps.layout.title', 'Vorlage wählen'),
description: t('invites.workflow.steps.layout.description', 'Wähle ein Layout und passe Texte, Farben und QR-Elemente an.'),
status: mapStatus('layout', layoutReady),
},
{
key: 'share',
title: t('invites.workflow.steps.share.title', 'Links & QR teilen'),
description: t('invites.workflow.steps.share.description', 'Aktiviere Gästelinks, kopiere QR-Codes und verteile sie im Team.'),
status: mapStatus('share', shareReady),
},
{
key: 'export',
title: t('invites.workflow.steps.export.title', 'Drucken & Export'),
description: t('invites.workflow.steps.export.description', 'Erzeuge PDFs oder PNGs für den Druck deiner Karten.'),
status: mapStatus('export', exportReady),
},
];
}, [activeTab, effectiveCustomization, exportElements.length, exportPreview, state.invites.length, t]);
async function handleCreateInvite() {
if (!slug || creatingInvite) {
return;
@@ -830,6 +886,8 @@ export default function EventInvitesPage(): React.ReactElement {
title={eventName}
subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')}
actions={actions}
tabs={eventTabs}
currentTabKey="invites"
>
{limitWarnings.length > 0 && (
<div className="mb-6 space-y-2">
@@ -887,17 +945,19 @@ export default function EventInvitesPage(): React.ReactElement {
</Card>
) : null}
<InviteWorkflowSteps steps={workflowSteps} onSelectStep={(tab) => handleTabChange(tab)} />
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
<TabsTrigger value="layout" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
{t('invites.tabs.layout', 'Layout anpassen')}
</TabsTrigger>
<TabsTrigger value="share" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
{t('invites.tabs.share', 'Links & QR teilen')}
</TabsTrigger>
<TabsTrigger value="export" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
{t('invites.tabs.export', 'Drucken & Export')}
</TabsTrigger>
<TabsTrigger value="links" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
{t('invites.tabs.links', 'QR-Codes verwalten')}
</TabsTrigger>
</TabsList>
{state.error ? (
@@ -1220,7 +1280,17 @@ export default function EventInvitesPage(): React.ReactElement {
</Card>
</TabsContent>
<TabsContent value="links" className="space-y-6 focus-visible:outline-hidden">
<TabsContent value="share" className="space-y-6 focus-visible:outline-hidden">
{primaryInvite ? (
<InviteShareSummaryCard
invite={primaryInvite}
onCopy={() => handleCopy(primaryInvite)}
onCreate={handleCreateInvite}
onOpenLayout={() => handleTabChange('layout')}
onOpenExport={() => handleTabChange('export')}
stats={inviteCountSummary}
/>
) : null}
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
@@ -1292,6 +1362,152 @@ export default function EventInvitesPage(): React.ReactElement {
);
}
type InviteWorkflowStep = {
key: TabKey;
title: string;
description: string;
status: 'done' | 'active' | 'pending';
};
function InviteWorkflowSteps({ steps, onSelectStep }: { steps: InviteWorkflowStep[]; onSelectStep: (tab: TabKey) => void }) {
const { t } = useTranslation('management');
return (
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)]/80 shadow-sm shadow-primary/10">
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-base font-semibold text-foreground">
{t('invites.workflow.title', 'Einladungs-Workflow')}
</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{t('invites.workflow.description', 'Durchlaufe die Schritte in Reihenfolge Layout gestalten, Links teilen, Export starten.')}
</CardDescription>
</div>
<Badge variant="outline" className="border-primary/30 text-xs uppercase tracking-[0.2em] text-primary">
{t('invites.workflow.badge', 'Setup')}
</Badge>
</CardHeader>
<CardContent className="grid gap-3 lg:grid-cols-3">
{steps.map((step) => {
const isDone = step.status === 'done';
const isActive = step.status === 'active';
return (
<button
key={step.key}
type="button"
className={`flex flex-col gap-2 rounded-2xl border px-4 py-3 text-left transition ${
isActive
? 'border-primary bg-primary/5 text-primary'
: isDone
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] text-muted-foreground'
}`}
onClick={() => onSelectStep(step.key)}
>
<div className="flex items-center gap-2">
{isDone ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Circle className="h-4 w-4" />
)}
<span className="text-sm font-semibold">{step.title}</span>
</div>
<p className="text-xs leading-snug text-current/80">{step.description}</p>
</button>
);
})}
</CardContent>
</Card>
);
}
type InviteShareSummaryProps = {
invite: EventQrInvite;
onCopy: () => void;
onCreate: () => void;
onOpenLayout: () => void;
onOpenExport: () => void;
stats: { active: number; total: number };
};
function InviteShareSummaryCard({ invite, onCopy, onCreate, onOpenLayout, onOpenExport, stats }: InviteShareSummaryProps) {
const { t } = useTranslation('management');
return (
<Card className="border border-[var(--tenant-border-strong)] bg-gradient-to-r from-[var(--tenant-surface-muted)] via-[var(--tenant-surface)] to-[var(--tenant-surface-strong)] shadow-lg shadow-primary/10">
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-lg text-foreground">
<Link2 className="h-5 w-5 text-primary" />
{t('invites.share.title', 'Schnellzugriff auf Gästelink')}
</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{t('invites.share.description', 'Nutze den Standardlink, um QR-Codes zu teilen oder weitere Karten zu erzeugen.')}
</CardDescription>
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-primary">
{t('invites.share.stats.active', { defaultValue: '{{count}} aktiv', count: stats.active })}
</Badge>
<Badge variant="outline" className="border-[var(--tenant-border-strong)] text-muted-foreground">
{t('invites.share.stats.total', { defaultValue: '{{count}} gesamt', count: stats.total })}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-2 rounded-2xl border border-[var(--tenant-border-strong)] bg-white/90 p-4 text-sm text-muted-foreground">
<span className="text-xs uppercase tracking-[0.3em] text-muted-foreground">{t('invites.share.primaryLabel', 'Hauptlink')}</span>
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<span className="break-all font-mono text-xs text-foreground">{invite.url}</span>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={onCopy}>
<Copy className="mr-1 h-3.5 w-3.5" />
{t('invites.share.actions.copy', 'Link kopieren')}
</Button>
{invite.url ? (
<Button
size="sm"
variant="ghost"
onClick={() => {
window.open(invite.url ?? '#', '_blank', 'noopener');
}}
className="text-primary"
>
<ExternalLink className="mr-1 h-3.5 w-3.5" />
{t('invites.share.actions.open', 'Öffnen')}
</Button>
) : null}
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Button variant="secondary" onClick={onOpenLayout} className="justify-between text-left">
<div>
<p className="text-sm font-semibold text-foreground">{t('invites.share.actions.editLayout', 'Layout bearbeiten')}</p>
<p className="text-xs text-muted-foreground">{t('invites.share.actions.editHint', 'Farben & Texte direkt im Editor anpassen.')}</p>
</div>
<ArrowLeft className="h-4 w-4 rotate-180" />
</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={onOpenExport} className="flex-1">
<Printer className="mr-2 h-4 w-4" />
{t('invites.share.actions.export', 'Drucken/Export')}
</Button>
<Button variant="outline" onClick={onCreate} className="flex-1">
<Share2 className="mr-2 h-4 w-4" />
{t('invites.share.actions.create', 'Weitere Einladung')}
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
<Mail className="mr-1 inline h-3.5 w-3.5 text-primary" />
{t('invites.share.hint', 'Teile den Link direkt im Team oder binde ihn im Newsletter ein.')}
</p>
</CardContent>
</Card>
);
}
function InviteCustomizerSkeleton(): React.ReactElement {
return (
<div className="space-y-6">