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

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

View File

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