aufgabenbearbeitung optimiert
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user