zu fabricjs gewechselt, noch nicht funktionsfähig

This commit is contained in:
Codex Agent
2025-10-31 20:19:09 +01:00
parent 06df61f706
commit eb0c31c90b
33 changed files with 7718 additions and 2062 deletions

View File

@@ -1,569 +1,7 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ArrowLeft,
Camera,
CheckCircle2,
Circle,
Loader2,
MessageSquare,
RefreshCw,
Send,
Sparkles,
ThumbsDown,
ThumbsUp,
QrCode,
} 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 { Textarea } from '@/components/ui/textarea';
import { AdminLayout } from '../components/AdminLayout';
import {
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_INVITES_PATH,
} from '../constants';
import {
EventToolkit,
EventToolkitTask,
getEventToolkit,
submitTenantFeedback,
TenantPhoto,
TenantEvent,
} from '../api';
import { isAuthError } from '../auth/tokens';
interface ToolkitState {
loading: boolean;
error: string | null;
data: EventToolkit | null;
}
import EventDetailPage from './EventDetailPage';
export default function EventToolkitPage(): JSX.Element {
const { slug } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t, i18n } = useTranslation('management');
const [state, setState] = React.useState<ToolkitState>({ loading: true, error: null, data: null });
const [feedbackSentiment, setFeedbackSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
const [feedbackMessage, setFeedbackMessage] = React.useState('');
const [feedbackSubmitting, setFeedbackSubmitting] = React.useState(false);
const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false);
const load = React.useCallback(async () => {
if (!slug) {
setState({ loading: false, error: t('toolkit.errors.missingSlug', 'Kein Event-Slug angegeben.'), data: null });
return;
}
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const toolkit = await getEventToolkit(slug);
setState({ loading: false, error: null, data: toolkit });
} catch (error) {
if (!isAuthError(error)) {
setState({ loading: false, error: t('toolkit.errors.loadFailed', 'Toolkit konnte nicht geladen werden.'), data: null });
}
}
}, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
const { data, loading } = state;
const eventName = data?.event ? resolveEventName(data.event.name, i18n.language) : '';
const actions = (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug ?? ''))}>
<ArrowLeft className="h-4 w-4" />
{t('toolkit.actions.backToEvent', 'Zurück zum Event')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug ?? ''))}>
<Camera className="h-4 w-4" />
{t('toolkit.actions.moderate', 'Fotos moderieren')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(slug ?? ''))}>
<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')}
</Button>
</div>
);
return (
<AdminLayout
title={eventName || t('toolkit.titleFallback', 'Event-Day Toolkit')}
subtitle={t('toolkit.subtitle', 'Behalte Uploads, Aufgaben und Einladungen am Eventtag im Blick.')}
actions={actions}
>
{state.error && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>{t('toolkit.alerts.errorTitle', 'Fehler')}</AlertTitle>
<AlertDescription>{state.error}</AlertDescription>
</Alert>
)}
{loading ? (
<ToolkitSkeleton />
) : data ? (
<div className="space-y-6">
<AlertList alerts={data.alerts} />
<MetricsGrid metrics={data.metrics} />
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
<PendingPhotosCard
photos={data.photos.pending}
navigateToModeration={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug ?? ''))}
/>
<InviteSummary invites={data.invites} navigateToEvent={() => navigate(ADMIN_EVENT_VIEW_PATH(slug ?? ''))} />
</div>
<div className="grid gap-6 lg:grid-cols-2">
<TaskOverviewCard tasks={data.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(slug ?? ''))} />
<RecentUploadsCard photos={data.photos.recent} />
</div>
<FeedbackCard
submitting={feedbackSubmitting}
submitted={feedbackSubmitted}
sentiment={feedbackSentiment}
message={feedbackMessage}
onSelectSentiment={setFeedbackSentiment}
onMessageChange={setFeedbackMessage}
onSubmit={async () => {
if (!slug) return;
setFeedbackSubmitting(true);
try {
await submitTenantFeedback({
category: 'event_toolkit',
sentiment: feedbackSentiment ?? undefined,
message: feedbackMessage ? feedbackMessage.trim() : undefined,
event_slug: slug,
});
setFeedbackSentiment(null);
setFeedbackMessage('');
setFeedbackSubmitted(true);
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
error: t('toolkit.errors.feedbackFailed', 'Feedback konnte nicht gesendet werden.'),
}));
}
} finally {
setFeedbackSubmitting(false);
}
}}
/>
</div>
) : null}
</AdminLayout>
);
}
function resolveEventName(name: TenantEvent['name'], locale?: string): string {
if (typeof name === 'string') {
return name;
}
if (name && typeof name === 'object') {
if (locale && name[locale]) {
return name[locale];
}
const short = locale && locale.includes('-') ? locale.split('-')[0] : null;
if (short && name[short]) {
return name[short];
}
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
}
return 'Event';
}
function AlertList({ alerts }: { alerts: string[] }) {
const { t } = useTranslation('management');
if (!alerts.length) {
return null;
}
const alertMap: Record<string, string> = {
no_tasks: t('toolkit.alerts.noTasks', 'Noch keine Tasks zugeordnet.'),
no_invites: t('toolkit.alerts.noInvites', 'Es gibt keine aktiven QR-Einladungen.'),
pending_photos: t('toolkit.alerts.pendingPhotos', 'Es warten Fotos auf Moderation.'),
};
return (
<div className="space-y-2">
{alerts.map((code) => (
<Alert key={code} variant="warning" className="border-amber-200 bg-amber-50 text-amber-900">
<AlertTitle>{t('toolkit.alerts.attention', 'Achtung')}</AlertTitle>
<AlertDescription>{alertMap[code] ?? code}</AlertDescription>
</Alert>
))}
</div>
);
}
function MetricsGrid({
metrics,
}: {
metrics: EventToolkit['metrics'];
}) {
const { t } = useTranslation('management');
const cards = [
{
label: t('toolkit.metrics.uploadsTotal', 'Uploads gesamt'),
value: metrics.uploads_total,
},
{
label: t('toolkit.metrics.uploads24h', 'Uploads (24h)'),
value: metrics.uploads_24h,
},
{
label: t('toolkit.metrics.pendingPhotos', 'Unmoderierte Fotos'),
value: metrics.pending_photos,
},
{
label: t('toolkit.metrics.activeInvites', 'Aktive Einladungen'),
value: metrics.active_invites,
},
{
label: t('toolkit.metrics.engagementMode', 'Modus'),
value:
metrics.engagement_mode === 'photo_only'
? t('toolkit.metrics.modePhotoOnly', 'Foto-Modus')
: t('toolkit.metrics.modeTasks', 'Aufgaben'),
},
];
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{cards.map((card) => (
<Card key={card.label} className="border-0 bg-white/90 shadow-sm shadow-amber-100/50">
<CardContent className="space-y-1 p-4">
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
<p className="text-2xl font-semibold text-slate-900">{card.value}</p>
</CardContent>
</Card>
))}
</div>
);
}
function PendingPhotosCard({
photos,
navigateToModeration,
}: {
photos: TenantPhoto[];
navigateToModeration: () => void;
}) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-xl shadow-slate-100/70">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Camera className="h-5 w-5 text-amber-500" />
{t('toolkit.pending.title', 'Wartende Fotos')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('toolkit.pending.subtitle', 'Moderationsempfehlung für neue Uploads.')}
</CardDescription>
</div>
<Button variant="outline" onClick={navigateToModeration}>
{t('toolkit.pending.cta', 'Zur Moderation')}
</Button>
</CardHeader>
<CardContent className="space-y-3">
{photos.length === 0 ? (
<p className="text-xs text-slate-500">{t('toolkit.pending.empty', 'Aktuell warten keine Fotos auf Freigabe.')}</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{photos.map((photo) => (
<div key={photo.id} className="flex gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3">
<img
src={photo.thumbnail_url}
alt={photo.filename}
className="h-16 w-16 rounded-lg object-cover"
/>
<div className="space-y-1 text-xs text-slate-600">
<p className="font-semibold text-slate-800">{photo.uploader_name ?? t('toolkit.pending.unknownUploader', 'Unbekannter Gast')}</p>
<p>{t('toolkit.pending.uploadedAt', 'Hochgeladen:')} {formatDateTime(photo.uploaded_at)}</p>
<p className="text-[11px] text-amber-700">{t('toolkit.pending.statusPending', 'Status: Prüfung ausstehend')}</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
function InviteSummary({
invites,
navigateToEvent,
}: {
invites: EventToolkit['invites'];
navigateToEvent: () => void;
}) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-md shadow-amber-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<Sparkles className="h-5 w-5 text-amber-500" />
{t('toolkit.invites.title', 'QR-Einladungen')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('toolkit.invites.subtitle', 'Aktive Links und Layouts im Blick behalten.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-xs text-slate-600">
<div className="flex gap-2 text-sm text-slate-900">
<Badge variant="outline" className="border-amber-200 text-amber-700">
{t('toolkit.invites.activeCount', { defaultValue: '{{count}} aktiv', count: invites.summary.active })}
</Badge>
<Badge variant="outline" className="border-amber-200 text-amber-700">
{t('toolkit.invites.totalCount', { defaultValue: '{{count}} gesamt', count: invites.summary.total })}
</Badge>
</div>
{invites.items.length === 0 ? (
<p>{t('toolkit.invites.empty', 'Noch keine QR-Einladungen erstellt.')}</p>
) : (
<ul className="space-y-2">
{invites.items.map((invite) => (
<li key={invite.id} className="rounded-lg border border-amber-100 bg-amber-50/70 p-3">
<p className="text-sm font-semibold text-slate-900">{invite.label ?? invite.url}</p>
<p className="truncate text-xs text-slate-500">{invite.url}</p>
<p className="text-[11px] text-amber-700">
{invite.is_active
? t('toolkit.invites.statusActive', 'Aktiv')
: t('toolkit.invites.statusInactive', 'Inaktiv')}
</p>
</li>
))}
</ul>
)}
<Button variant="outline" onClick={navigateToEvent}>
{t('toolkit.invites.manage', 'Einladungen verwalten')}
</Button>
</CardContent>
</Card>
);
}
function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tasks']; navigateToTasks: () => void }) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-md shadow-pink-100/60">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-pink-500" />
{t('toolkit.tasks.title', 'Aktive Aufgaben')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('toolkit.tasks.subtitle', 'Motiviere Gäste mit klaren Aufgaben & Highlights.')}
</CardDescription>
</div>
<Badge variant="outline" className="border-pink-200 text-pink-600">
{t('toolkit.tasks.summary', {
defaultValue: '{{completed}} von {{total}} erledigt',
completed: tasks.summary.completed,
total: tasks.summary.total,
})}
</Badge>
</CardHeader>
<CardContent className="space-y-2">
{tasks.items.length === 0 ? (
<p className="text-xs text-slate-500">{t('toolkit.tasks.empty', 'Noch keine Aufgaben zugewiesen.')}</p>
) : (
<div className="space-y-2">
{tasks.items.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</div>
)}
<Button variant="outline" onClick={navigateToTasks}>
{t('toolkit.tasks.manage', 'Tasks verwalten')}
</Button>
</CardContent>
</Card>
);
}
function TaskRow({ task }: { task: EventToolkitTask }) {
const { t } = useTranslation('management');
return (
<div className="flex items-start justify-between gap-3 rounded-xl border border-pink-100 bg-white/80 p-3">
<div>
<p className="text-sm font-medium text-slate-900">{task.title}</p>
{task.description ? <p className="text-xs text-slate-600">{task.description}</p> : null}
</div>
<span className={`flex items-center gap-1 text-xs font-medium ${task.is_completed ? 'text-emerald-600' : 'text-slate-500'}`}>
{task.is_completed ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
{task.is_completed ? t('toolkit.tasks.completed', 'Erledigt') : t('toolkit.tasks.open', 'Offen')}
</span>
</div>
);
}
function RecentUploadsCard({ photos }: { photos: TenantPhoto[] }) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-md shadow-sky-100/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<Camera className="h-5 w-5 text-sky-500" />
{t('toolkit.recent.title', 'Neueste Uploads')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('toolkit.recent.subtitle', 'Ein Blick auf die letzten Fotos der Gäste.')}
</CardDescription>
</CardHeader>
<CardContent>
{photos.length === 0 ? (
<p className="text-xs text-slate-500">{t('toolkit.recent.empty', 'Noch keine freigegebenen Fotos vorhanden.')}</p>
) : (
<div className="grid grid-cols-3 gap-2">
{photos.map((photo) => (
<img
key={photo.id}
src={photo.thumbnail_url}
alt={photo.filename}
className="h-24 w-full rounded-lg object-cover"
/>
))}
</div>
)}
</CardContent>
</Card>
);
}
function FeedbackCard({
submitting,
submitted,
sentiment,
message,
onSelectSentiment,
onMessageChange,
onSubmit,
}: {
submitting: boolean;
submitted: boolean;
sentiment: 'positive' | 'neutral' | 'negative' | null;
message: string;
onSelectSentiment: (value: 'positive' | 'neutral' | 'negative') => void;
onMessageChange: (value: string) => void;
onSubmit: () => Promise<void>;
}) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/95 shadow-lg shadow-amber-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<MessageSquare className="h-5 w-5 text-amber-500" />
{t('toolkit.feedback.title', 'Wie hilfreich ist dieses Toolkit?')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('toolkit.feedback.subtitle', 'Dein Feedback hilft uns, den Eventtag noch besser zu begleiten.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{submitted ? (
<Alert variant="success">
<AlertTitle>{t('toolkit.feedback.thanksTitle', 'Danke!')}</AlertTitle>
<AlertDescription>{t('toolkit.feedback.thanksDescription', 'Wir haben dein Feedback erhalten.')}</AlertDescription>
</Alert>
) : (
<>
<div className="flex flex-wrap gap-3">
<Button
type="button"
variant={sentiment === 'positive' ? 'default' : 'outline'}
onClick={() => onSelectSentiment('positive')}
disabled={submitting}
>
<ThumbsUp className="mr-2 h-4 w-4" /> {t('toolkit.feedback.positive', 'Hilfreich')}
</Button>
<Button
type="button"
variant={sentiment === 'neutral' ? 'default' : 'outline'}
onClick={() => onSelectSentiment('neutral')}
disabled={submitting}
>
<Sparkles className="mr-2 h-4 w-4" /> {t('toolkit.feedback.neutral', 'Ganz okay')}
</Button>
<Button
type="button"
variant={sentiment === 'negative' ? 'default' : 'outline'}
onClick={() => onSelectSentiment('negative')}
disabled={submitting}
>
<ThumbsDown className="mr-2 h-4 w-4" /> {t('toolkit.feedback.negative', 'Verbesserungsbedarf')}
</Button>
</div>
<div className="space-y-2">
<Textarea
rows={3}
placeholder={t('toolkit.feedback.placeholder', 'Erzähle uns kurz, was dir gefallen hat oder was fehlt …')}
value={message}
onChange={(event) => onMessageChange(event.target.value)}
disabled={submitting}
/>
<p className="text-[11px] text-slate-500">
{t('toolkit.feedback.disclaimer', 'Dein Feedback wird vertraulich behandelt und hilft uns beim Feinschliff.')}
</p>
</div>
<div className="flex justify-end">
<Button onClick={() => void onSubmit()} disabled={submitting || (!sentiment && message.trim() === '')}>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
{t('toolkit.feedback.submit', 'Feedback senden')}
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
}
function ToolkitSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function formatDateTime(value: string | null): string {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleString();
return <EventDetailPage mode="toolkit" />;
}