Files
fotospiel-app/resources/js/admin/pages/EventToolkitPage.tsx
2025-10-28 18:28:22 +01:00

564 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
} 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,
} 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;
}
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={() => 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();
}