aufgabenbearbeitung optimiert

This commit is contained in:
Codex Agent
2025-12-01 12:04:25 +01:00
parent 96f8c5d63c
commit b8e515a03c
2 changed files with 666 additions and 88 deletions

View File

@@ -1,7 +1,7 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; 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 { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast'; 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 { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@@ -41,6 +42,7 @@ import {
importTaskCollection, importTaskCollection,
getEmotions, getEmotions,
updateEvent, updateEvent,
updateTask,
TenantEvent, TenantEvent,
TenantTask, TenantTask,
TenantTaskCollection, TenantTaskCollection,
@@ -48,7 +50,7 @@ import {
} from '../api'; } from '../api';
import { EmotionsSection } from './EmotionsPage'; import { EmotionsSection } from './EmotionsPage';
import { isAuthError } from '../auth/tokens'; 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 { filterEmotionsByEventType } from '../lib/emotions';
import { buildEventTabs } from '../lib/eventTabs'; import { buildEventTabs } from '../lib/eventTabs';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
@@ -70,6 +72,8 @@ export default function EventTasksPage() {
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [tab, setTab] = React.useState<'tasks' | 'packs'>('tasks'); const [tab, setTab] = React.useState<'tasks' | 'packs'>('tasks');
const [taskSearch, setTaskSearch] = React.useState(''); const [taskSearch, setTaskSearch] = React.useState('');
const [debouncedTaskSearch, setDebouncedTaskSearch] = React.useState('');
const [difficultyFilter, setDifficultyFilter] = React.useState<TenantTask['difficulty'] | ''>('');
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]); const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
const [collectionsLoading, setCollectionsLoading] = React.useState(false); const [collectionsLoading, setCollectionsLoading] = React.useState(false);
const [collectionsError, setCollectionsError] = React.useState<string | null>(null); const [collectionsError, setCollectionsError] = React.useState<string | null>(null);
@@ -85,6 +89,15 @@ export default function EventTasksPage() {
const [newTaskDifficulty, setNewTaskDifficulty] = React.useState<TenantTask['difficulty'] | ''>(''); const [newTaskDifficulty, setNewTaskDifficulty] = React.useState<TenantTask['difficulty'] | ''>('');
const [creatingTask, setCreatingTask] = React.useState(false); const [creatingTask, setCreatingTask] = React.useState(false);
const [draggingId, setDraggingId] = React.useState<number | null>(null); 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) => { const hydrateTasks = React.useCallback(async (targetEvent: TenantEvent) => {
try { try {
const [refreshed, libraryTasks] = await Promise.all([ const [refreshed, libraryTasks] = await Promise.all([
@@ -191,12 +204,22 @@ export default function EventTasksPage() {
const set = new Set(emotionFilter); const set = new Set(emotionFilter);
list = list.filter((task) => (task.emotion_id ? set.has(task.emotion_id) : false)); 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; return list;
} }
const term = taskSearch.toLowerCase(); return list.filter((task) => `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(debouncedTaskSearch));
return list.filter((task) => `${task.title ?? ''} ${task.description ?? ''}`.toLowerCase().includes(term)); }, [assignedTasks, debouncedTaskSearch, emotionFilter, difficultyFilter]);
}, [assignedTasks, taskSearch, emotionFilter]);
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 } })); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
@@ -225,7 +248,7 @@ export default function EventTasksPage() {
setSaving(false); setSaving(false);
} }
}, },
[availableTasks, event, hydrateTasks, t], [availableTasks, event, t],
); );
const handleDetachSingle = React.useCallback( const handleDetachSingle = React.useCallback(
@@ -253,7 +276,7 @@ export default function EventTasksPage() {
setSaving(false); setSaving(false);
} }
}, },
[assignedTasks, event, hydrateTasks, t], [assignedTasks, event, t],
); );
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
@@ -318,7 +341,7 @@ export default function EventTasksPage() {
} finally { } finally {
setCreatingTask(false); setCreatingTask(false);
} }
}, [event, newTaskDescription, newTaskEmotionId, newTaskTitle, hydrateTasks, t]); }, [event, newTaskDescription, newTaskEmotionId, newTaskTitle, newTaskDifficulty, emotions, hydrateTasks, t]);
const eventTabs = React.useMemo(() => { const eventTabs = React.useMemo(() => {
if (!event) { if (!event) {
@@ -416,7 +439,11 @@ export default function EventTasksPage() {
}, [event, hydrateTasks, slug, t]); }, [event, hydrateTasks, slug, t]);
const tasksEnabled = React.useMemo(() => { 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'; return mode !== 'photo_only';
}, [event?.engagement_mode, event?.settings]); }, [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 = ( const actions = (
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600"> <Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@@ -584,11 +745,42 @@ export default function EventTasksPage() {
> >
<CardContent className="grid gap-4 lg:grid-cols-2"> <CardContent className="grid gap-4 lg:grid-cols-2">
<section className="space-y-3"> <section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <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"> <h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<Sparkles className="h-4 w-4 text-pink-500" /> <Sparkles className="h-4 w-4 text-pink-500" />
{t('sections.assigned.title', 'Zugeordnete Tasks')} {t('sections.assigned.title', 'Zugeordnete Tasks')}
</h3> </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"> <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" /> <Search className="h-4 w-4 text-slate-500" />
<Input <Input
@@ -598,10 +790,81 @@ export default function EventTasksPage() {
className="h-8 border-0 bg-transparent text-sm focus-visible:ring-0" className="h-8 border-0 bg-transparent text-sm focus-visible:ring-0"
/> />
</div> </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> </div>
{emotionChips.length > 0 ? ( {emotionChips.length > 0 && emotionFilterOpen ? (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2 rounded-xl border border-slate-200 bg-white/80 p-2">
<Button <Button
size="sm" size="sm"
variant={emotionFilter.length === 0 ? 'default' : 'outline'} variant={emotionFilter.length === 0 ? 'default' : 'outline'}
@@ -654,6 +917,20 @@ export default function EventTasksPage() {
task={task} task={task}
origin="assigned" origin="assigned"
onRemove={() => void handleDetachSingle(task.id)} 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> </div>
@@ -732,15 +1009,69 @@ export default function EventTasksPage() {
{availableTasks.length === 0 ? ( {availableTasks.length === 0 ? (
<EmptyState message={t('sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} /> <EmptyState message={t('sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} />
) : ( ) : (
availableTasks.map((task) => ( <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 <DraggableTaskCard
key={task.id} key={task.id}
task={task} task={task}
origin="library" origin="library"
onAdd={() => void handleAssignSingle(task.id)} 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> </div>
</DropZone> </DropZone>
@@ -757,6 +1088,51 @@ export default function EventTasksPage() {
</DndContext> </DndContext>
</Card> </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 <EmotionsCard
emotions={relevantEmotions} emotions={relevantEmotions}
emotionsLoading={emotionsLoading} emotionsLoading={emotionsLoading}
@@ -828,12 +1204,22 @@ function DraggableTaskCard({
onRemove, onRemove,
onAdd, onAdd,
disabled, disabled,
showCheckbox,
checked,
onCheckedChange,
onInlineUpdate,
inlineSaving,
}: { }: {
task: TenantTask; task: TenantTask;
origin: 'assigned' | 'library'; origin: 'assigned' | 'library';
onRemove?: () => void; onRemove?: () => void;
onAdd?: () => void; onAdd?: () => void;
disabled?: boolean; 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 { t } = useTranslation('management');
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useDraggable({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useDraggable({
@@ -841,6 +1227,15 @@ function DraggableTaskCard({
data: { list: origin }, 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 = { const style = {
transform: transform ? CSS.Translate.toString(transform) : undefined, transform: transform ? CSS.Translate.toString(transform) : undefined,
transition: transition || undefined, transition: transition || undefined,
@@ -852,11 +1247,19 @@ function DraggableTaskCard({
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className="rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 shadow-sm" 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 <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} {...listeners}
{...attributes} {...attributes}
disabled={disabled} disabled={disabled}
@@ -864,13 +1267,66 @@ function DraggableTaskCard({
> >
</button> </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>
<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"> <div className="flex items-center gap-2">
{task.emotion ? ( <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 <Badge
variant="outline" variant="outline"
className="border-transparent text-[11px]" className="border-transparent text-[11px]"
@@ -883,9 +1339,25 @@ function DraggableTaskCard({
{task.emotion.name} {task.emotion.name}
</Badge> </Badge>
) : null} ) : null}
{!editing ? (
<Badge variant="outline" className="border-pink-200 text-pink-600"> <Badge variant="outline" className="border-pink-200 text-pink-600">
{mapPriority(task.priority, (key, fallback) => t(key, { defaultValue: fallback }))} {mapPriority(task.priority, (key, fallback) => t(key, { defaultValue: fallback }))}
</Badge> </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={() => setEditing((prev) => !prev)}
aria-label={t('actions.edit', 'Bearbeiten')}
disabled={inlineSaving}
>
{editing ? <X className="h-4 w-4" /> : <Pencil className="h-4 w-4 text-slate-500" />}
</Button>
{origin === 'assigned' ? ( {origin === 'assigned' ? (
<Button <Button
size="icon" size="icon"
@@ -910,6 +1382,7 @@ function DraggableTaskCard({
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -18,13 +18,16 @@ import {
createTask, createTask,
deleteTask, deleteTask,
getTasks, getTasks,
getEmotions,
PaginationMeta, PaginationMeta,
TenantTask, TenantTask,
TenantEmotion,
TaskPayload, TaskPayload,
updateTask, updateTask,
} from '../api'; } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { buildEngagementTabPath } from '../constants'; import { buildEngagementTabPath } from '../constants';
import { filterEmotionsByEventType } from '../lib/emotions';
type TaskFormState = { type TaskFormState = {
title: string; title: string;
@@ -56,6 +59,10 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
const [meta, setMeta] = React.useState<PaginationMeta | null>(null); const [meta, setMeta] = React.useState<PaginationMeta | null>(null);
const [page, setPage] = React.useState(1); const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState(''); 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 [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [dialogOpen, setDialogOpen] = React.useState(false); const [dialogOpen, setDialogOpen] = React.useState(false);
@@ -68,7 +75,7 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
setLoading(true); setLoading(true);
setError(null); setError(null);
getTasks({ page, search: search.trim() || undefined }) getTasks({ page, per_page: 200, search: search.trim() || undefined })
.then((result) => { .then((result) => {
if (cancelled) return; if (cancelled) return;
setTasks(result.data); setTasks(result.data);
@@ -90,6 +97,45 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
}; };
}, [page, search, t]); }, [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(() => { const openCreate = React.useCallback(() => {
setEditingTask(null); setEditingTask(null);
setForm(INITIAL_FORM); setForm(INITIAL_FORM);
@@ -222,24 +268,83 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
}} }}
className="max-w-sm" 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"> <div className="text-xs text-slate-500">
{t('pagination.page', { {t('pagination.page', {
current: meta.current_page, current: meta?.current_page ?? 1,
total: meta.last_page, total: meta?.last_page ?? 1,
count: meta.total, count: filteredTasks.length,
})} })}
</div> </div>
) : null} </div>
</div> </div>
{loading ? ( {loading ? (
<TasksSkeleton /> <TasksSkeleton />
) : tasks.length === 0 ? ( ) : tasks.length === 0 ? (
<EmptyState onCreate={openCreate} /> <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"> <div className="grid gap-3">
{tasks.map((task) => ( {filteredTasks.map((task) => (
<TaskRow <TaskRow
key={task.id} key={task.id}
task={task} task={task}