aufgabenbearbeitung optimiert
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Layers, Loader2, PlusCircle, Search, Sparkles } from 'lucide-react';
|
||||
import { ArrowLeft, Layers, Loader2, PlusCircle, Search, Sparkles, Pencil, Check, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -12,8 +12,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
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 { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
importTaskCollection,
|
||||
getEmotions,
|
||||
updateEvent,
|
||||
updateTask,
|
||||
TenantEvent,
|
||||
TenantTask,
|
||||
TenantTaskCollection,
|
||||
@@ -48,7 +50,7 @@ import {
|
||||
} from '../api';
|
||||
import { EmotionsSection } from './EmotionsPage';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_BRANDING_PATH, buildEngagementTabPath } from '../constants';
|
||||
import { ADMIN_EVENTS_PATH, buildEngagementTabPath } from '../constants';
|
||||
import { filterEmotionsByEventType } from '../lib/emotions';
|
||||
import { buildEventTabs } from '../lib/eventTabs';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
@@ -70,6 +72,8 @@ export default function EventTasksPage() {
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [tab, setTab] = React.useState<'tasks' | 'packs'>('tasks');
|
||||
const [taskSearch, setTaskSearch] = React.useState('');
|
||||
const [debouncedTaskSearch, setDebouncedTaskSearch] = React.useState('');
|
||||
const [difficultyFilter, setDifficultyFilter] = React.useState<TenantTask['difficulty'] | ''>('');
|
||||
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
|
||||
const [collectionsLoading, setCollectionsLoading] = React.useState(false);
|
||||
const [collectionsError, setCollectionsError] = React.useState<string | null>(null);
|
||||
@@ -85,6 +89,15 @@ export default function EventTasksPage() {
|
||||
const [newTaskDifficulty, setNewTaskDifficulty] = React.useState<TenantTask['difficulty'] | ''>('');
|
||||
const [creatingTask, setCreatingTask] = React.useState(false);
|
||||
const [draggingId, setDraggingId] = React.useState<number | null>(null);
|
||||
const [selectedAssignedIds, setSelectedAssignedIds] = React.useState<number[]>([]);
|
||||
const [selectedAvailableIds, setSelectedAvailableIds] = React.useState<number[]>([]);
|
||||
const [batchSaving, setBatchSaving] = React.useState(false);
|
||||
const [inlineSavingId, setInlineSavingId] = React.useState<number | null>(null);
|
||||
const [emotionFilterOpen, setEmotionFilterOpen] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const handle = window.setTimeout(() => setDebouncedTaskSearch(taskSearch.trim().toLowerCase()), 180);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [taskSearch]);
|
||||
const hydrateTasks = React.useCallback(async (targetEvent: TenantEvent) => {
|
||||
try {
|
||||
const [refreshed, libraryTasks] = await Promise.all([
|
||||
@@ -191,12 +204,22 @@ export default function EventTasksPage() {
|
||||
const set = new Set(emotionFilter);
|
||||
list = list.filter((task) => (task.emotion_id ? set.has(task.emotion_id) : false));
|
||||
}
|
||||
if (!taskSearch.trim()) {
|
||||
if (difficultyFilter) {
|
||||
list = list.filter((task) => task.difficulty === difficultyFilter);
|
||||
}
|
||||
if (!debouncedTaskSearch) {
|
||||
return list;
|
||||
}
|
||||
const term = taskSearch.toLowerCase();
|
||||
return list.filter((task) => `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(term));
|
||||
}, [assignedTasks, taskSearch, emotionFilter]);
|
||||
return list.filter((task) => `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(debouncedTaskSearch));
|
||||
}, [assignedTasks, debouncedTaskSearch, emotionFilter, difficultyFilter]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedAssignedIds((prev) => prev.filter((id) => assignedTasks.some((task) => task.id === id)));
|
||||
}, [assignedTasks]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedAvailableIds((prev) => prev.filter((id) => availableTasks.some((task) => task.id === id)));
|
||||
}, [availableTasks]);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
||||
|
||||
@@ -225,7 +248,7 @@ export default function EventTasksPage() {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[availableTasks, event, hydrateTasks, t],
|
||||
[availableTasks, event, t],
|
||||
);
|
||||
|
||||
const handleDetachSingle = React.useCallback(
|
||||
@@ -253,7 +276,7 @@ export default function EventTasksPage() {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[assignedTasks, event, hydrateTasks, t],
|
||||
[assignedTasks, event, t],
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
@@ -318,7 +341,7 @@ export default function EventTasksPage() {
|
||||
} finally {
|
||||
setCreatingTask(false);
|
||||
}
|
||||
}, [event, newTaskDescription, newTaskEmotionId, newTaskTitle, hydrateTasks, t]);
|
||||
}, [event, newTaskDescription, newTaskEmotionId, newTaskTitle, newTaskDifficulty, emotions, hydrateTasks, t]);
|
||||
|
||||
const eventTabs = React.useMemo(() => {
|
||||
if (!event) {
|
||||
@@ -416,7 +439,11 @@ export default function EventTasksPage() {
|
||||
}, [event, hydrateTasks, slug, t]);
|
||||
|
||||
const tasksEnabled = React.useMemo(() => {
|
||||
const mode = event?.engagement_mode ?? (event?.settings as any)?.engagement_mode;
|
||||
const settingsMode =
|
||||
event?.settings && typeof event.settings === 'object' && 'engagement_mode' in (event.settings as Record<string, unknown>)
|
||||
? (event.settings as { engagement_mode?: string }).engagement_mode
|
||||
: undefined;
|
||||
const mode = event?.engagement_mode ?? settingsMode;
|
||||
return mode !== 'photo_only';
|
||||
}, [event?.engagement_mode, event?.settings]);
|
||||
|
||||
@@ -482,6 +509,140 @@ export default function EventTasksPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssignSelected = React.useCallback(async () => {
|
||||
if (!event || selectedAvailableIds.length === 0) return;
|
||||
const ids = selectedAvailableIds;
|
||||
const move = availableTasks.filter((task) => ids.includes(task.id));
|
||||
if (!move.length) return;
|
||||
|
||||
const nextAvailableSet = new Set(ids);
|
||||
const prevAssigned = assignedTasks;
|
||||
const prevAvailable = availableTasks;
|
||||
|
||||
setAssignedTasks((prev) => [...prev, ...move]);
|
||||
setAvailableTasks((prev) => prev.filter((task) => !nextAvailableSet.has(task.id)));
|
||||
setSelectedAvailableIds([]);
|
||||
setBatchSaving(true);
|
||||
try {
|
||||
await assignTasksToEvent(event.id, ids);
|
||||
toast.success(t('actions.assignedToast', 'Tasks wurden zugewiesen.'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('errors.assign', 'Tasks konnten nicht zugewiesen werden.'));
|
||||
}
|
||||
setAssignedTasks(prevAssigned);
|
||||
setAvailableTasks(prevAvailable);
|
||||
} finally {
|
||||
setBatchSaving(false);
|
||||
}
|
||||
}, [event, selectedAvailableIds, availableTasks, assignedTasks, t]);
|
||||
|
||||
const handleDetachSelected = React.useCallback(async () => {
|
||||
if (!event || selectedAssignedIds.length === 0) return;
|
||||
const ids = selectedAssignedIds;
|
||||
const move = assignedTasks.filter((task) => ids.includes(task.id));
|
||||
if (!move.length) return;
|
||||
|
||||
const nextAssignedSet = new Set(ids);
|
||||
const prevAssigned = assignedTasks;
|
||||
const prevAvailable = availableTasks;
|
||||
|
||||
setAssignedTasks((prev) => prev.filter((task) => !nextAssignedSet.has(task.id)));
|
||||
setAvailableTasks((prev) => [...prev, ...move]);
|
||||
setSelectedAssignedIds([]);
|
||||
setBatchSaving(true);
|
||||
try {
|
||||
await detachTasksFromEvent(event.id, ids);
|
||||
toast.success(t('actions.removedToast', 'Tasks wurden entfernt.'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('errors.remove', 'Tasks konnten nicht entfernt werden.'));
|
||||
}
|
||||
setAssignedTasks(prevAssigned);
|
||||
setAvailableTasks(prevAvailable);
|
||||
} finally {
|
||||
setBatchSaving(false);
|
||||
}
|
||||
}, [event, selectedAssignedIds, assignedTasks, availableTasks, t]);
|
||||
|
||||
const handleInlineUpdate = React.useCallback(
|
||||
async (taskId: number, payload: { title?: string; difficulty?: TenantTask['difficulty'] | '' }) => {
|
||||
if (!event) return;
|
||||
|
||||
const prevAssigned = assignedTasks;
|
||||
const prevAvailable = availableTasks;
|
||||
const existingAssigned = assignedTasks.find((task) => task.id === taskId);
|
||||
const existingAvailable = availableTasks.find((task) => task.id === taskId);
|
||||
const optimistic = existingAssigned ?? existingAvailable;
|
||||
if (!optimistic) {
|
||||
return;
|
||||
}
|
||||
|
||||
const patch: Partial<TenantTask> = {
|
||||
...payload,
|
||||
title: payload.title ?? optimistic.title,
|
||||
difficulty: payload.difficulty || null,
|
||||
} as Partial<TenantTask>;
|
||||
|
||||
if (existingAssigned) {
|
||||
setAssignedTasks((prev) => prev.map((task) => (task.id === taskId ? { ...task, ...patch } : task)));
|
||||
}
|
||||
if (existingAvailable) {
|
||||
setAvailableTasks((prev) => prev.map((task) => (task.id === taskId ? { ...task, ...patch } : task)));
|
||||
}
|
||||
|
||||
setInlineSavingId(taskId);
|
||||
const duplicateAndAssign = async () => {
|
||||
const created = await createTask({
|
||||
title: patch.title,
|
||||
difficulty: patch.difficulty ?? undefined,
|
||||
description: optimistic.description ?? null,
|
||||
priority: optimistic.priority ?? undefined,
|
||||
emotion_id: optimistic.emotion_id ?? undefined,
|
||||
event_type_id: optimistic.event_type_id ?? undefined,
|
||||
});
|
||||
await assignTasksToEvent(event.id, [created.id]);
|
||||
setAssignedTasks((prev) => {
|
||||
const withoutOld = prev.filter((task) => task.id !== taskId);
|
||||
return [...withoutOld, { ...created, emotion: optimistic.emotion ?? created.emotion ?? null }];
|
||||
});
|
||||
setAvailableTasks((prev) => prev.filter((task) => task.id !== taskId));
|
||||
toast.success(t('actions.created', 'Aufgabe erstellt und zugewiesen.'));
|
||||
};
|
||||
|
||||
try {
|
||||
if (optimistic.tenant_id === null) {
|
||||
await duplicateAndAssign();
|
||||
} else {
|
||||
const updated = await updateTask(taskId, {
|
||||
title: patch.title,
|
||||
difficulty: payload.difficulty || undefined,
|
||||
description: optimistic.description ?? undefined,
|
||||
priority: optimistic.priority ?? undefined,
|
||||
emotion_id: optimistic.emotion_id ?? undefined,
|
||||
});
|
||||
|
||||
if (existingAssigned) {
|
||||
setAssignedTasks((prev) => prev.map((task) => (task.id === taskId ? { ...task, ...updated } : task)));
|
||||
}
|
||||
if (existingAvailable) {
|
||||
setAvailableTasks((prev) => prev.map((task) => (task.id === taskId ? { ...task, ...updated } : task)));
|
||||
}
|
||||
toast.success(t('actions.updated', 'Task aktualisiert.'));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('errors.update', 'Task konnte nicht aktualisiert werden.'));
|
||||
}
|
||||
setAssignedTasks(prevAssigned);
|
||||
setAvailableTasks(prevAvailable);
|
||||
} finally {
|
||||
setInlineSavingId(null);
|
||||
}
|
||||
},
|
||||
[event, assignedTasks, availableTasks, t],
|
||||
);
|
||||
|
||||
const actions = (
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@@ -582,26 +743,128 @@ export default function EventTasksPage() {
|
||||
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" />
|
||||
{t('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('sections.assigned.search', 'Aufgaben suchen...')}
|
||||
className="h-8 border-0 bg-transparent text-sm focus-visible:ring-0"
|
||||
/>
|
||||
<CardContent className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-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('sections.assigned.title', 'Zugeordnete Tasks')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredAssignedTasks.length > 0 && filteredAssignedTasks.every((task) => selectedAssignedIds.includes(task.id))
|
||||
? true
|
||||
: selectedAssignedIds.some((id) => filteredAssignedTasks.some((task) => task.id === id))
|
||||
? 'indeterminate'
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedAssignedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
filteredAssignedTasks.forEach((task) => next.add(task.id));
|
||||
return Array.from(next);
|
||||
});
|
||||
} else {
|
||||
const visibleIds = new Set(filteredAssignedTasks.map((task) => task.id));
|
||||
setSelectedAssignedIds((prev) => prev.filter((id) => !visibleIds.has(id)));
|
||||
}
|
||||
}}
|
||||
aria-label={t('sections.assigned.selectAll', 'Alle sichtbaren Aufgaben auswählen')}
|
||||
/>
|
||||
<span className="text-xs text-slate-600">
|
||||
{t('sections.assigned.selectedCount', {
|
||||
defaultValue: '{{count}} ausgewählt',
|
||||
count: selectedAssignedIds.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<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('sections.assigned.search', 'Aufgaben suchen...')}
|
||||
className="h-8 border-0 bg-transparent text-sm focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700">
|
||||
<span>{t('sections.assigned.results', { defaultValue: '{{count}} Treffer', count: filteredAssignedTasks.length })}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={difficultyFilter ? 'default' : 'outline'}
|
||||
className="rounded-full"
|
||||
onClick={() => setDifficultyFilter((prev) => (prev ? '' : 'medium'))}
|
||||
aria-expanded={difficultyFilter ? true : false}
|
||||
>
|
||||
{difficultyFilter
|
||||
? t('sections.assigned.difficulty.active', { defaultValue: 'Schwierigkeit: ' }) +
|
||||
t(
|
||||
difficultyFilter === 'easy'
|
||||
? 'sections.assigned.difficulty.easy'
|
||||
: difficultyFilter === 'medium'
|
||||
? 'sections.assigned.difficulty.medium'
|
||||
: 'sections.assigned.difficulty.hard',
|
||||
difficultyFilter === 'easy' ? 'Leicht' : difficultyFilter === 'medium' ? 'Mittel' : 'Schwer',
|
||||
)
|
||||
: t('sections.assigned.difficulty.filter', 'Nach Schwierigkeit filtern')}
|
||||
</Button>
|
||||
{difficultyFilter ? (
|
||||
<div className="flex flex-wrap gap-2 rounded-xl border border-slate-200 bg-white/80 p-2">
|
||||
{(['', 'easy', 'medium', 'hard'] as Array<TenantTask['difficulty'] | ''>).map((value) => {
|
||||
const active = difficultyFilter === value;
|
||||
const label =
|
||||
value === 'easy'
|
||||
? t('sections.assigned.difficulty.easy', 'Leicht')
|
||||
: value === 'medium'
|
||||
? t('sections.assigned.difficulty.medium', 'Mittel')
|
||||
: value === 'hard'
|
||||
? t('sections.assigned.difficulty.hard', 'Schwer')
|
||||
: t('sections.assigned.difficulty.all', 'Alle Stufen');
|
||||
return (
|
||||
<Button
|
||||
key={value || 'all'}
|
||||
size="sm"
|
||||
variant={active ? 'default' : 'outline'}
|
||||
className="rounded-full"
|
||||
onClick={() => setDifficultyFilter(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{emotionChips.length > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={emotionFilterOpen ? 'default' : 'outline'}
|
||||
className="rounded-full"
|
||||
onClick={() => setEmotionFilterOpen((prev) => !prev)}
|
||||
>
|
||||
{emotionFilterOpen
|
||||
? t('filters.emotionsHide', 'Emotionen ausblenden')
|
||||
: t('filters.emotionsShow', 'Emotionen filtern')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleDetachSelected()}
|
||||
disabled={selectedAssignedIds.length === 0 || batchSaving || saving}
|
||||
>
|
||||
{t('actions.removeSelected', 'Auswahl entfernen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{emotionChips.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{emotionChips.length > 0 && emotionFilterOpen ? (
|
||||
<div className="flex flex-wrap gap-2 rounded-xl border border-slate-200 bg-white/80 p-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={emotionFilter.length === 0 ? 'default' : 'outline'}
|
||||
@@ -654,6 +917,20 @@ export default function EventTasksPage() {
|
||||
task={task}
|
||||
origin="assigned"
|
||||
onRemove={() => void handleDetachSingle(task.id)}
|
||||
showCheckbox
|
||||
checked={selectedAssignedIds.includes(task.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedAssignedIds((prev) => {
|
||||
if (checked) {
|
||||
if (prev.includes(task.id)) return prev;
|
||||
return [...prev, task.id];
|
||||
}
|
||||
return prev.filter((id) => id !== task.id);
|
||||
});
|
||||
}}
|
||||
disabled={batchSaving || saving}
|
||||
onInlineUpdate={handleInlineUpdate}
|
||||
inlineSaving={inlineSavingId === task.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -702,10 +979,10 @@ export default function EventTasksPage() {
|
||||
<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'] | '')}
|
||||
>
|
||||
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>
|
||||
@@ -728,20 +1005,74 @@ export default function EventTasksPage() {
|
||||
</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) => (
|
||||
<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.')} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 pb-1">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-700">
|
||||
<Checkbox
|
||||
checked={
|
||||
availableTasks.length > 0 && availableTasks.every((task) => selectedAvailableIds.includes(task.id))
|
||||
? true
|
||||
: selectedAvailableIds.some((id) => availableTasks.some((task) => task.id === id))
|
||||
? 'indeterminate'
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedAvailableIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
availableTasks.forEach((task) => next.add(task.id));
|
||||
return Array.from(next);
|
||||
});
|
||||
} else {
|
||||
setSelectedAvailableIds([]);
|
||||
}
|
||||
}}
|
||||
aria-label={t('sections.library.selectAll', 'Alle Bibliotheks-Tasks auswählen')}
|
||||
/>
|
||||
<span>
|
||||
{t('sections.library.selectedCount', {
|
||||
defaultValue: '{{count}} ausgewählt',
|
||||
count: selectedAvailableIds.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleAssignSelected()}
|
||||
disabled={selectedAvailableIds.length === 0 || !tasksEnabled || batchSaving || saving}
|
||||
>
|
||||
{t('actions.assignSelected', 'Auswahl zuweisen')}
|
||||
</Button>
|
||||
</div>
|
||||
{availableTasks.map((task) => (
|
||||
<DraggableTaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
origin="library"
|
||||
onAdd={() => void handleAssignSingle(task.id)}
|
||||
disabled={!tasksEnabled}
|
||||
disabled={!tasksEnabled || saving || batchSaving}
|
||||
showCheckbox
|
||||
checked={selectedAvailableIds.includes(task.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedAvailableIds((prev) => {
|
||||
if (checked) {
|
||||
if (prev.includes(task.id)) return prev;
|
||||
return [...prev, task.id];
|
||||
}
|
||||
return prev.filter((id) => id !== task.id);
|
||||
});
|
||||
}}
|
||||
onInlineUpdate={handleInlineUpdate}
|
||||
inlineSaving={inlineSavingId === task.id}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DropZone>
|
||||
</section>
|
||||
@@ -757,6 +1088,51 @@ export default function EventTasksPage() {
|
||||
</DndContext>
|
||||
</Card>
|
||||
|
||||
<div className="sticky bottom-3 z-10 flex flex-col gap-2 rounded-2xl border border-slate-200 bg-white/95 p-3 shadow-xl shadow-slate-200 lg:top-4 lg:bottom-auto">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-700">
|
||||
<span className="font-semibold text-slate-900">
|
||||
{t('sections.bulk.title', 'Batch-Aktionen')}
|
||||
</span>
|
||||
<Badge variant="outline" className="border-slate-200 text-slate-700">
|
||||
{t('sections.bulk.assignedSelected', {
|
||||
defaultValue: '{{count}} ausgewählt (Zugeordnet)',
|
||||
count: selectedAssignedIds.length,
|
||||
})}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-slate-200 text-slate-700">
|
||||
{t('sections.bulk.librarySelected', {
|
||||
defaultValue: '{{count}} ausgewählt (Bibliothek)',
|
||||
count: selectedAvailableIds.length,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleAssignSelected()}
|
||||
disabled={selectedAvailableIds.length === 0 || !tasksEnabled || batchSaving || saving}
|
||||
>
|
||||
{t('actions.assignSelected', 'Auswahl zuweisen')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleDetachSelected()}
|
||||
disabled={selectedAssignedIds.length === 0 || batchSaving || saving}
|
||||
>
|
||||
{t('actions.removeSelected', 'Auswahl entfernen')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => navigate(buildEngagementTabPath('tasks'))}
|
||||
>
|
||||
{t('library.open', 'Aufgaben-Bibliothek öffnen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmotionsCard
|
||||
emotions={relevantEmotions}
|
||||
emotionsLoading={emotionsLoading}
|
||||
@@ -828,12 +1204,22 @@ function DraggableTaskCard({
|
||||
onRemove,
|
||||
onAdd,
|
||||
disabled,
|
||||
showCheckbox,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
onInlineUpdate,
|
||||
inlineSaving,
|
||||
}: {
|
||||
task: TenantTask;
|
||||
origin: 'assigned' | 'library';
|
||||
onRemove?: () => void;
|
||||
onAdd?: () => void;
|
||||
disabled?: boolean;
|
||||
showCheckbox?: boolean;
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
onInlineUpdate?: (taskId: number, payload: { title?: string; difficulty?: TenantTask['difficulty'] | '' }) => void;
|
||||
inlineSaving?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useDraggable({
|
||||
@@ -841,6 +1227,15 @@ function DraggableTaskCard({
|
||||
data: { list: origin },
|
||||
});
|
||||
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
const [titleValue, setTitleValue] = React.useState(task.title ?? '');
|
||||
const [difficultyValue, setDifficultyValue] = React.useState<TenantTask['difficulty'] | ''>(task.difficulty ?? '');
|
||||
|
||||
React.useEffect(() => {
|
||||
setTitleValue(task.title ?? '');
|
||||
setDifficultyValue(task.difficulty ?? '');
|
||||
}, [task.title, task.difficulty]);
|
||||
|
||||
const style = {
|
||||
transform: transform ? CSS.Translate.toString(transform) : undefined,
|
||||
transition: transition || undefined,
|
||||
@@ -852,11 +1247,19 @@ function DraggableTaskCard({
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 shadow-sm"
|
||||
onDoubleClick={() => setEditing(true)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{showCheckbox ? (
|
||||
<Checkbox
|
||||
checked={checked ?? false}
|
||||
onCheckedChange={(state) => onCheckedChange?.(Boolean(state))}
|
||||
aria-label={t('actions.selectTask', 'Task auswählen')}
|
||||
/>
|
||||
) : null}
|
||||
<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"
|
||||
className="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}
|
||||
@@ -864,49 +1267,119 @@ function DraggableTaskCard({
|
||||
>
|
||||
⋮⋮
|
||||
</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' ? (
|
||||
<div className="flex-1 space-y-2 min-w-0">
|
||||
<div className="space-y-1 min-w-0 max-w-full">
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={titleValue}
|
||||
onChange={(e) => setTitleValue(e.target.value)}
|
||||
disabled={inlineSaving}
|
||||
className="min-h-[64px]"
|
||||
/>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-slate-200 bg-white px-2 text-sm"
|
||||
value={difficultyValue}
|
||||
onChange={(e) => setDifficultyValue(e.target.value as TenantTask['difficulty'] | '')}
|
||||
disabled={inlineSaving}
|
||||
>
|
||||
<option value="">{t('sections.assigned.difficulty.all', 'Alle Stufen')}</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 className="flex items-center gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="h-8 w-8"
|
||||
disabled={!titleValue.trim() || inlineSaving}
|
||||
onClick={() => {
|
||||
if (!onInlineUpdate) return;
|
||||
onInlineUpdate(task.id, { title: titleValue.trim(), difficulty: difficultyValue });
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
{inlineSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
disabled={inlineSaving}
|
||||
onClick={() => {
|
||||
setTitleValue(task.title ?? '');
|
||||
setDifficultyValue(task.difficulty ?? '');
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-slate-900 break-words">{task.title}</p>
|
||||
{task.description ? <p className="text-xs text-slate-600 break-words">{task.description}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!editing && 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}
|
||||
{!editing ? (
|
||||
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
||||
{mapPriority(task.priority, (key, fallback) => t(key, { defaultValue: fallback }))}
|
||||
</Badge>
|
||||
) : null}
|
||||
{task.tenant_id !== null ? (
|
||||
<Badge variant="outline" className="border-emerald-200 text-emerald-700">
|
||||
{t('tasks.customBadge', 'Eigene Aufgabe')}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onRemove}
|
||||
disabled={disabled}
|
||||
aria-label={t('actions.remove', 'Vom Event entfernen')}
|
||||
onClick={() => setEditing((prev) => !prev)}
|
||||
aria-label={t('actions.edit', 'Bearbeiten')}
|
||||
disabled={inlineSaving}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-slate-500" />
|
||||
{editing ? <X className="h-4 w-4" /> : <Pencil 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>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,13 +18,16 @@ import {
|
||||
createTask,
|
||||
deleteTask,
|
||||
getTasks,
|
||||
getEmotions,
|
||||
PaginationMeta,
|
||||
TenantTask,
|
||||
TenantEmotion,
|
||||
TaskPayload,
|
||||
updateTask,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { buildEngagementTabPath } from '../constants';
|
||||
import { filterEmotionsByEventType } from '../lib/emotions';
|
||||
|
||||
type TaskFormState = {
|
||||
title: string;
|
||||
@@ -56,6 +59,10 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
const [meta, setMeta] = React.useState<PaginationMeta | null>(null);
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [eventTypeFilter, setEventTypeFilter] = React.useState<string>('all');
|
||||
const [ownershipFilter, setOwnershipFilter] = React.useState<'all' | 'custom' | 'global'>('all');
|
||||
const [emotionFilter, setEmotionFilter] = React.useState<number | null>(null);
|
||||
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
@@ -68,7 +75,7 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
getTasks({ page, search: search.trim() || undefined })
|
||||
getTasks({ page, per_page: 200, search: search.trim() || undefined })
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setTasks(result.data);
|
||||
@@ -90,6 +97,45 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
};
|
||||
}, [page, search, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
getEmotions()
|
||||
.then((list) => {
|
||||
if (!cancelled) {
|
||||
setEmotions(list);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const eventTypeOptions = React.useMemo(() => {
|
||||
const options = new Map<string, string>();
|
||||
tasks.forEach((task) => {
|
||||
const slug = task.event_type?.slug ?? 'none';
|
||||
const name = task.event_type?.name ?? 'Allgemein';
|
||||
options.set(slug, name);
|
||||
});
|
||||
return Array.from(options.entries());
|
||||
}, [tasks]);
|
||||
|
||||
const filteredTasks = React.useMemo(() => {
|
||||
return tasks.filter((task) => {
|
||||
if (eventTypeFilter !== 'all') {
|
||||
const slug = task.event_type?.slug ?? 'none';
|
||||
if (slug !== eventTypeFilter) return false;
|
||||
}
|
||||
if (ownershipFilter === 'custom' && task.tenant_id === null) return false;
|
||||
if (ownershipFilter === 'global' && task.tenant_id !== null) return false;
|
||||
if (emotionFilter !== null) {
|
||||
if (task.emotion_id !== emotionFilter) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [tasks, eventTypeFilter, ownershipFilter, emotionFilter]);
|
||||
|
||||
const openCreate = React.useCallback(() => {
|
||||
setEditingTask(null);
|
||||
setForm(INITIAL_FORM);
|
||||
@@ -222,24 +268,83 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
}}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
{meta && meta.total > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 text-xs text-slate-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-slate-700">{t('filters.eventType', 'Event-Typ')}</span>
|
||||
<Select value={eventTypeFilter} onValueChange={(value) => setEventTypeFilter(value)}>
|
||||
<SelectTrigger className="h-8 w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('filters.eventTypeAll', 'Alle')}</SelectItem>
|
||||
{eventTypeOptions.map(([slug, name]) => (
|
||||
<SelectItem key={slug} value={slug}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-slate-700">{t('filters.ownership', 'Quelle')}</span>
|
||||
<div className="flex gap-1 rounded-lg border border-slate-200 p-1">
|
||||
{(['all', 'custom', 'global'] as const).map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
size="sm"
|
||||
variant={ownershipFilter === key ? 'default' : 'ghost'}
|
||||
className="h-8"
|
||||
onClick={() => setOwnershipFilter(key)}
|
||||
>
|
||||
{key === 'all'
|
||||
? t('filters.ownershipAll', 'Alle')
|
||||
: key === 'custom'
|
||||
? t('filters.ownershipCustom', 'Selbst angelegt')
|
||||
: t('filters.ownershipGlobal', 'Standard')}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-slate-700">{t('filters.emotion', 'Emotion')}</span>
|
||||
<Select
|
||||
value={emotionFilter ? String(emotionFilter) : 'all'}
|
||||
onValueChange={(value) => setEmotionFilter(value === 'all' ? null : Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('filters.emotionAll', 'Alle')}</SelectItem>
|
||||
{emotions.map((emotion) => (
|
||||
<SelectItem key={emotion.id} value={String(emotion.id)}>
|
||||
{emotion.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{t('pagination.page', {
|
||||
current: meta.current_page,
|
||||
total: meta.last_page,
|
||||
count: meta.total,
|
||||
current: meta?.current_page ?? 1,
|
||||
total: meta?.last_page ?? 1,
|
||||
count: filteredTasks.length,
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<TasksSkeleton />
|
||||
) : tasks.length === 0 ? (
|
||||
<EmptyState onCreate={openCreate} />
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50/70 p-6 text-sm text-slate-600">
|
||||
{t('filters.noResults', 'Keine Aufgaben zu den Filtern gefunden.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{tasks.map((task) => (
|
||||
{filteredTasks.map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
|
||||
Reference in New Issue
Block a user