rework of the event admin UI
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Loader2, PlusCircle, Sparkles } from 'lucide-react';
|
||||
import { ArrowLeft, Layers, Loader2, PlusCircle, Search, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -9,6 +11,8 @@ 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 { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
@@ -16,12 +20,20 @@ import {
|
||||
getEvent,
|
||||
getEventTasks,
|
||||
getTasks,
|
||||
getTaskCollections,
|
||||
importTaskCollection,
|
||||
getEmotions,
|
||||
updateEvent,
|
||||
TenantEvent,
|
||||
TenantTask,
|
||||
TenantTaskCollection,
|
||||
TenantEmotion,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { ADMIN_EVENTS_PATH } from '../constants';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_INVITES_PATH, buildEngagementTabPath } from '../constants';
|
||||
import { extractBrandingPalette } from '../lib/branding';
|
||||
import { filterEmotionsByEventType } from '../lib/emotions';
|
||||
import { buildEventTabs } from '../lib/eventTabs';
|
||||
|
||||
export default function EventTasksPage() {
|
||||
const { t } = useTranslation(['management', 'dashboard']);
|
||||
@@ -38,6 +50,27 @@ export default function EventTasksPage() {
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [modeSaving, setModeSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [tab, setTab] = React.useState<'tasks' | 'packs'>('tasks');
|
||||
const [taskSearch, setTaskSearch] = React.useState('');
|
||||
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
|
||||
const [collectionsLoading, setCollectionsLoading] = React.useState(false);
|
||||
const [collectionsError, setCollectionsError] = React.useState<string | null>(null);
|
||||
const [importingCollectionId, setImportingCollectionId] = React.useState<number | null>(null);
|
||||
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
|
||||
const [emotionsLoading, setEmotionsLoading] = React.useState(false);
|
||||
const [emotionsError, setEmotionsError] = React.useState<string | null>(null);
|
||||
const hydrateTasks = React.useCallback(async (targetEvent: TenantEvent) => {
|
||||
try {
|
||||
const refreshed = await getEventTasks(targetEvent.id, 1);
|
||||
const assignedIds = new Set(refreshed.data.map((task) => task.id));
|
||||
setAssignedTasks(refreshed.data);
|
||||
setAvailableTasks((prev) => prev.filter((task) => !assignedIds.has(task.id)));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(t('management.tasks.errors.assign', 'Tasks konnten nicht geladen werden.'));
|
||||
}
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const statusLabels = React.useMemo(
|
||||
() => ({
|
||||
@@ -47,6 +80,12 @@ export default function EventTasksPage() {
|
||||
[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],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) {
|
||||
setError(t('management.tasks.errors.missingSlug', 'Kein Event-Slug angegeben.'));
|
||||
@@ -118,6 +157,108 @@ export default function EventTasksPage() {
|
||||
setSelected((current) => current.filter((taskId) => availableTasks.some((task) => task.id === taskId)));
|
||||
}, [availableTasks]);
|
||||
|
||||
const filteredAssignedTasks = React.useMemo(() => {
|
||||
if (!taskSearch.trim()) {
|
||||
return assignedTasks;
|
||||
}
|
||||
const term = taskSearch.toLowerCase();
|
||||
return assignedTasks.filter((task) => `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(term));
|
||||
}, [assignedTasks, taskSearch]);
|
||||
|
||||
const eventTabs = React.useMemo(() => {
|
||||
if (!event) {
|
||||
return [];
|
||||
}
|
||||
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return buildEventTabs(event, translateMenu, {
|
||||
photos: event.photo_count ?? 0,
|
||||
tasks: assignedTasks.length,
|
||||
invites: event.active_invites_count ?? event.total_invites_count ?? 0,
|
||||
});
|
||||
}, [event, assignedTasks.length, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!event?.event_type?.slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setCollectionsLoading(true);
|
||||
setCollectionsError(null);
|
||||
getTaskCollections({ per_page: 6, event_type: event.event_type.slug })
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setCollections(result.data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
if (!isAuthError(err)) {
|
||||
setCollectionsError(t('management.tasks.collections.error', 'Kollektionen konnten nicht geladen werden.'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setCollectionsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [event?.event_type?.slug, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setEmotionsLoading(true);
|
||||
setEmotionsError(null);
|
||||
getEmotions()
|
||||
.then((list) => {
|
||||
if (!cancelled) {
|
||||
setEmotions(list);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (!isAuthError(err)) {
|
||||
setEmotionsError(t('tasks.emotions.error', 'Emotionen konnten nicht geladen werden.'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setEmotionsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const handleImportCollection = React.useCallback(async (collection: TenantTaskCollection) => {
|
||||
if (!slug || !event) {
|
||||
return;
|
||||
}
|
||||
setImportingCollectionId(collection.id);
|
||||
try {
|
||||
await importTaskCollection(collection.id, slug);
|
||||
toast.success(
|
||||
t('management.tasks.collections.imported', {
|
||||
defaultValue: 'Mission Pack "{{name}}" importiert.',
|
||||
name: collection.name,
|
||||
}),
|
||||
);
|
||||
await hydrateTasks(event);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('management.tasks.collections.importFailed', 'Mission Pack konnte nicht importiert werden.'));
|
||||
}
|
||||
} finally {
|
||||
setImportingCollectionId(null);
|
||||
}
|
||||
}, [event, hydrateTasks, slug, t]);
|
||||
|
||||
const isPhotoOnlyMode = event?.engagement_mode === 'photo_only';
|
||||
|
||||
async function handleModeChange(checked: boolean) {
|
||||
@@ -159,6 +300,8 @@ export default function EventTasksPage() {
|
||||
title={t('management.tasks.title', 'Event-Tasks')}
|
||||
subtitle={t('management.tasks.subtitle', 'Verwalte Aufgaben, die diesem Event zugeordnet sind.')}
|
||||
actions={actions}
|
||||
tabs={eventTabs}
|
||||
currentTabKey="tasks"
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
@@ -176,116 +319,173 @@ export default function EventTasksPage() {
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<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('management.tasks.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 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{t('management.tasks.modes.title', 'Aufgaben & Foto-Modus')}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
{isPhotoOnlyMode
|
||||
? t(
|
||||
'management.tasks.modes.photoOnlyHint',
|
||||
'Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.',
|
||||
)
|
||||
: t(
|
||||
'management.tasks.modes.tasksHint',
|
||||
'Aufgaben werden in der Gäste-App angezeigt. Deaktiviere sie für einen reinen Foto-Modus.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">
|
||||
{isPhotoOnlyMode
|
||||
? t('management.tasks.modes.photoOnly', 'Foto-Modus')
|
||||
: t('management.tasks.modes.tasks', 'Aufgaben aktiv')}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isPhotoOnlyMode}
|
||||
onCheckedChange={handleModeChange}
|
||||
disabled={modeSaving}
|
||||
aria-label={t('management.tasks.modes.switchLabel', 'Foto-Modus aktivieren')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{modeSaving ? (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('management.tasks.modes.updating', 'Einstellung wird gespeichert ...')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<Sparkles className="h-4 w-4 text-pink-500" />
|
||||
{t('management.tasks.sections.assigned.title', 'Zugeordnete Tasks')}
|
||||
</h3>
|
||||
{assignedTasks.length === 0 ? (
|
||||
<EmptyState message={t('management.tasks.sections.assigned.empty', 'Noch keine Tasks zugewiesen.')} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{assignedTasks.map((task) => (
|
||||
<div key={task.id} className="rounded-2xl border border-slate-100 bg-white/90 p-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, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
{task.description && <p className="mt-1 text-xs text-slate-600">{task.description}</p>}
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as 'tasks' | 'packs')} className="space-y-6">
|
||||
<TabsList className="grid gap-2 rounded-2xl bg-slate-100/80 p-1 sm:grid-cols-2">
|
||||
<TabsTrigger value="tasks">{t('management.tasks.tabs.tasks', 'Aufgaben')}</TabsTrigger>
|
||||
<TabsTrigger value="packs">{t('management.tasks.tabs.packs', 'Mission Packs')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<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('management.tasks.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>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{t('management.tasks.modes.title', 'Aufgaben & Foto-Modus')}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
{isPhotoOnlyMode
|
||||
? t(
|
||||
'management.tasks.modes.photoOnlyHint',
|
||||
'Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.',
|
||||
)
|
||||
: t(
|
||||
'management.tasks.modes.tasksHint',
|
||||
'Aufgaben sind aktiv. Gäste sehen Mission Cards in der App.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<PlusCircle className="h-4 w-4 text-emerald-500" />
|
||||
{t('management.tasks.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('management.tasks.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)
|
||||
)
|
||||
}
|
||||
disabled={isPhotoOnlyMode}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">
|
||||
{isPhotoOnlyMode
|
||||
? t('management.tasks.modes.photoOnly', 'Foto-Modus')
|
||||
: t('management.tasks.modes.tasks', 'Aufgaben aktiv')}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isPhotoOnlyMode}
|
||||
onCheckedChange={handleModeChange}
|
||||
disabled={modeSaving}
|
||||
aria-label={t('management.tasks.modes.switchLabel', 'Foto-Modus aktivieren')}
|
||||
/>
|
||||
<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 || isPhotoOnlyMode}
|
||||
>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.tasks.actions.assign', 'Ausgewählte Tasks zuweisen')}
|
||||
</Button>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{modeSaving ? (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('management.tasks.modes.updating', 'Einstellung wird gespeichert ...')}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-3 text-xs sm:grid-cols-3">
|
||||
<SummaryPill
|
||||
label={t('management.tasks.summary.assigned', 'Zugeordnete Tasks')}
|
||||
value={assignedTasks.length}
|
||||
/>
|
||||
<SummaryPill
|
||||
label={t('management.tasks.summary.library', 'Bibliothek')}
|
||||
value={availableTasks.length}
|
||||
/>
|
||||
<SummaryPill
|
||||
label={t('management.tasks.summary.mode', 'Aktiver Modus')}
|
||||
value={isPhotoOnlyMode ? t('management.tasks.summary.photoOnly', 'Nur Fotos') : t('management.tasks.summary.tasksMode', 'Mission Cards')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<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" />
|
||||
{t('management.tasks.sections.assigned.title', 'Zugeordnete Tasks')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1">
|
||||
<Search className="h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
value={taskSearch}
|
||||
onChange={(event) => setTaskSearch(event.target.value)}
|
||||
placeholder={t('management.tasks.sections.assigned.search', 'Aufgaben suchen...')}
|
||||
className="h-8 border-0 bg-transparent text-sm focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredAssignedTasks.length === 0 ? (
|
||||
<EmptyState
|
||||
message={
|
||||
taskSearch.trim()
|
||||
? t('management.tasks.sections.assigned.noResults', 'Keine Aufgaben zum Suchbegriff.')
|
||||
: t('management.tasks.sections.assigned.empty', 'Noch keine Tasks zugewiesen.')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredAssignedTasks.map((task) => (
|
||||
<AssignedTaskRow key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<PlusCircle className="h-4 w-4 text-emerald-500" />
|
||||
{t('management.tasks.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('management.tasks.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)
|
||||
)
|
||||
}
|
||||
disabled={isPhotoOnlyMode}
|
||||
/>
|
||||
<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 || isPhotoOnlyMode}
|
||||
>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.tasks.actions.assign', 'Ausgewählte Tasks zuweisen')}
|
||||
</Button>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<BrandingStoryPanel
|
||||
event={event}
|
||||
palette={palette}
|
||||
emotions={relevantEmotions}
|
||||
emotionsLoading={emotionsLoading}
|
||||
emotionsError={emotionsError}
|
||||
collections={collections}
|
||||
onOpenBranding={() => {
|
||||
if (!slug) return;
|
||||
navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=layout`);
|
||||
}}
|
||||
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
|
||||
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="packs">
|
||||
<MissionPackGrid
|
||||
collections={collections}
|
||||
loading={collectionsLoading}
|
||||
error={collectionsError}
|
||||
onImport={handleImportCollection}
|
||||
importingId={importingCollectionId}
|
||||
onViewAll={() => navigate(buildEngagementTabPath('collections'))}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</AdminLayout>
|
||||
@@ -310,6 +510,249 @@ function TaskSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function AssignedTaskRow({ task }: { task: TenantTask }) {
|
||||
const { t } = useTranslation('management');
|
||||
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>
|
||||
{task.description && <p className="mt-1 text-xs text-slate-600">{task.description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MissionPackGrid({
|
||||
collections,
|
||||
loading,
|
||||
error,
|
||||
onImport,
|
||||
importingId,
|
||||
onViewAll,
|
||||
}: {
|
||||
collections: TenantTaskCollection[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onImport: (collection: TenantTaskCollection) => void;
|
||||
importingId: number | null;
|
||||
onViewAll: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<Card className="border border-slate-200 bg-white/90">
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-base text-slate-900">
|
||||
<Layers className="h-5 w-5 text-pink-500" />
|
||||
{t('management.tasks.collections.title', 'Mission Packs')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('management.tasks.collections.subtitle', 'Importiere Aufgaben-Kollektionen, die zu deinem Event passen.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" onClick={onViewAll}>
|
||||
{t('management.tasks.collections.viewAll', 'Alle Kollektionen ansehen')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('management.tasks.collections.errorTitle', 'Kollektionen nicht verfügbar')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-24 animate-pulse rounded-2xl bg-slate-100/60" />
|
||||
))}
|
||||
</div>
|
||||
) : collections.length === 0 ? (
|
||||
<EmptyState message={t('management.tasks.collections.empty', 'Keine empfohlenen Kollektionen gefunden.')} />
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{collections.map((collection) => (
|
||||
<div key={collection.id} className="flex flex-col rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<p className="text-sm font-semibold text-slate-900">{collection.name}</p>
|
||||
{collection.description ? (
|
||||
<p className="text-xs text-slate-500">{collection.description}</p>
|
||||
) : null}
|
||||
<Badge variant="outline" className="w-fit border-slate-200 text-slate-600">
|
||||
{t('management.tasks.collections.tasksCount', {
|
||||
defaultValue: '{{count}} Aufgaben',
|
||||
count: collection.tasks_count,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between text-xs text-slate-500">
|
||||
<span>{collection.event_type?.name ?? t('management.tasks.collections.genericType', 'Allgemein')}</span>
|
||||
<span>{collection.is_global ? t('management.tasks.collections.global', 'Global') : t('management.tasks.collections.custom', 'Custom')}</span>
|
||||
</div>
|
||||
<Button
|
||||
className="mt-4 rounded-full bg-brand-rose text-white"
|
||||
disabled={importingId === collection.id}
|
||||
onClick={() => onImport(collection)}
|
||||
>
|
||||
{importingId === collection.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
t('management.tasks.collections.importCta', 'Mission Pack importieren')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type BrandingStoryPanelProps = {
|
||||
event: TenantEvent;
|
||||
palette: ReturnType<typeof extractBrandingPalette>;
|
||||
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) {
|
||||
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]);
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-xl shadow-indigo-100/50">
|
||||
<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.')}
|
||||
</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')}
|
||||
</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>
|
||||
</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>
|
||||
</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':
|
||||
|
||||
Reference in New Issue
Block a user