553 lines
21 KiB
TypeScript
553 lines
21 KiB
TypeScript
import React from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { CheckCircle2, Circle, Loader2, Pencil, Plus, Trash2 } from 'lucide-react';
|
|
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Switch } from '@/components/ui/switch';
|
|
|
|
import { AdminLayout } from '../components/AdminLayout';
|
|
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;
|
|
description: string;
|
|
priority: TaskPayload['priority'];
|
|
due_date: string;
|
|
is_completed: boolean;
|
|
};
|
|
|
|
const INITIAL_FORM: TaskFormState = {
|
|
title: '',
|
|
description: '',
|
|
priority: 'medium',
|
|
due_date: '',
|
|
is_completed: false,
|
|
};
|
|
|
|
export type TasksSectionProps = {
|
|
embedded?: boolean;
|
|
onNavigateToCollections?: () => void;
|
|
};
|
|
|
|
export function TasksSection({ embedded = false, onNavigateToCollections }: TasksSectionProps) {
|
|
const navigate = useNavigate();
|
|
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
|
|
const { t: tc } = useTranslation('common');
|
|
|
|
const [tasks, setTasks] = React.useState<TenantTask[]>([]);
|
|
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);
|
|
const [editingTask, setEditingTask] = React.useState<TenantTask | null>(null);
|
|
const [form, setForm] = React.useState<TaskFormState>(INITIAL_FORM);
|
|
const [saving, setSaving] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
getTasks({ page, per_page: 200, search: search.trim() || undefined })
|
|
.then((result) => {
|
|
if (cancelled) return;
|
|
setTasks(result.data);
|
|
setMeta(result.meta);
|
|
})
|
|
.catch((err) => {
|
|
if (!isAuthError(err)) {
|
|
setError(t('errors.load'));
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [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);
|
|
setDialogOpen(true);
|
|
}, []);
|
|
|
|
const openEdit = React.useCallback((task: TenantTask) => {
|
|
setEditingTask(task);
|
|
setForm({
|
|
title: task.title,
|
|
description: task.description ?? '',
|
|
priority: task.priority ?? 'medium',
|
|
due_date: task.due_date ? task.due_date.slice(0, 10) : '',
|
|
is_completed: task.is_completed,
|
|
});
|
|
setDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleNavigateToCollections = React.useCallback(() => {
|
|
if (onNavigateToCollections) {
|
|
onNavigateToCollections();
|
|
return;
|
|
}
|
|
navigate(buildEngagementTabPath('collections'));
|
|
}, [navigate, onNavigateToCollections]);
|
|
|
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
if (!form.title.trim()) {
|
|
setError('Bitte gib einen Titel ein.');
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
setError(null);
|
|
const payload: TaskPayload = {
|
|
title: form.title.trim(),
|
|
description: form.description.trim() || null,
|
|
priority: form.priority ?? undefined,
|
|
due_date: form.due_date || undefined,
|
|
is_completed: form.is_completed,
|
|
};
|
|
|
|
try {
|
|
if (editingTask) {
|
|
const updated = await updateTask(editingTask.id, payload);
|
|
setTasks((prev) => prev.map((task) => (task.id === updated.id ? updated : task)));
|
|
} else {
|
|
const created = await createTask(payload);
|
|
setTasks((prev) => [created, ...prev]);
|
|
}
|
|
setDialogOpen(false);
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
setError('Task konnte nicht gespeichert werden.');
|
|
}
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleDelete(taskId: number) {
|
|
if (!window.confirm('Task wirklich löschen?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await deleteTask(taskId);
|
|
setTasks((prev) => prev.filter((task) => task.id !== taskId));
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
setError('Task konnte nicht gelöscht werden.');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function toggleCompletion(task: TenantTask) {
|
|
if (task.tenant_id === null) {
|
|
return;
|
|
}
|
|
try {
|
|
const updated = await updateTask(task.id, { is_completed: !task.is_completed });
|
|
setTasks((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry)));
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
setError('Status konnte nicht aktualisiert werden.');
|
|
}
|
|
}
|
|
}
|
|
|
|
const title = embedded ? t('titles.embedded') : t('titles.default');
|
|
const subtitle = embedded ? t('subtitles.embedded') : t('subtitles.default');
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertTitle>{t('errors.title')}</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
|
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<CardTitle className="text-xl text-slate-900">{title}</CardTitle>
|
|
<CardDescription className="text-sm text-slate-600">{subtitle}</CardDescription>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button variant="outline" onClick={handleNavigateToCollections}>
|
|
{tc('navigation.collections')}
|
|
</Button>
|
|
<Button
|
|
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
|
onClick={openCreate}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{t('actions.new')}
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<Input
|
|
placeholder={t('actions.searchPlaceholder')}
|
|
value={search}
|
|
onChange={(event) => {
|
|
setPage(1);
|
|
setSearch(event.target.value);
|
|
}}
|
|
className="max-w-sm"
|
|
/>
|
|
<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 ?? 1,
|
|
total: meta?.last_page ?? 1,
|
|
count: filteredTasks.length,
|
|
})}
|
|
</div>
|
|
</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">
|
|
{filteredTasks.map((task) => (
|
|
<TaskRow
|
|
key={task.id}
|
|
task={task}
|
|
onToggle={() => toggleCompletion(task)}
|
|
onEdit={() => openEdit(task)}
|
|
onDelete={() => handleDelete(task.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{meta && meta.last_page > 1 ? (
|
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-100 pt-4 text-sm">
|
|
<div className="text-slate-500">
|
|
{t('pagination.summary', { count: meta.total, current: meta.current_page, total: meta.last_page })}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => setPage((page) => Math.max(page - 1, 1))} disabled={meta.current_page <= 1}>
|
|
{t('pagination.prev')}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage((page) => Math.min(page + 1, meta.last_page ?? page + 1))}
|
|
disabled={meta.current_page >= (meta.last_page ?? 1)}
|
|
>
|
|
{t('pagination.next')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{editingTask ? t('form.editTitle') : t('form.createTitle')}</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="task-title">{t('form.title')}</Label>
|
|
<Input
|
|
id="task-title"
|
|
value={form.title}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="task-description">{t('form.description')}</Label>
|
|
<Input
|
|
id="task-description"
|
|
value={form.description}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
|
|
placeholder={t('form.descriptionPlaceholder')}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="task-priority">{t('form.priority')}</Label>
|
|
<Select
|
|
value={form.priority ?? 'medium'}
|
|
onValueChange={(value) =>
|
|
setForm((prev) => ({ ...prev, priority: value as TaskPayload['priority'] }))
|
|
}
|
|
>
|
|
<SelectTrigger id="task-priority">
|
|
<SelectValue placeholder={t('form.priorityPlaceholder')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="low">{t('priorities.low')}</SelectItem>
|
|
<SelectItem value="medium">{t('priorities.medium')}</SelectItem>
|
|
<SelectItem value="high">{t('priorities.high')}</SelectItem>
|
|
<SelectItem value="urgent">{t('priorities.urgent')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="task-due-date">{t('form.dueDate')}</Label>
|
|
<Input
|
|
id="task-due-date"
|
|
type="date"
|
|
value={form.due_date}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, due_date: event.target.value }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50/50 p-3">
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-700">{t('form.completedTitle')}</p>
|
|
<p className="text-xs text-slate-500">{t('form.completedCopy')}</p>
|
|
</div>
|
|
<Switch checked={form.is_completed} onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_completed: checked }))} />
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
|
{t('form.cancel')}
|
|
</Button>
|
|
<Button type="submit" disabled={saving} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
|
|
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
{t('form.save')}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function TasksPage() {
|
|
const navigate = useNavigate();
|
|
const { t: tc } = useTranslation('common');
|
|
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
|
|
return (
|
|
<AdminLayout
|
|
title={tc('navigation.tasks')}
|
|
subtitle={t('subtitles.default')}
|
|
>
|
|
<TasksSection onNavigateToCollections={() => navigate(buildEngagementTabPath('collections'))} />
|
|
</AdminLayout>
|
|
);
|
|
}
|
|
|
|
function TaskRow({
|
|
task,
|
|
onToggle,
|
|
onEdit,
|
|
onDelete,
|
|
}: {
|
|
task: TenantTask;
|
|
onToggle: () => void;
|
|
onEdit: () => void;
|
|
onDelete: () => void;
|
|
}) {
|
|
const isCompleted = task.is_completed;
|
|
const statusIcon = isCompleted ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> : <Circle className="h-4 w-4 text-slate-300" />;
|
|
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
|
|
return (
|
|
<div className="flex flex-col gap-3 rounded-xl border border-slate-200/70 bg-white/80 p-4 shadow-sm shadow-pink-100/20 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex items-start gap-3">
|
|
<button type="button" onClick={onToggle} className="mt-1 text-slate-500 transition-colors hover:text-emerald-500">
|
|
{statusIcon}
|
|
</button>
|
|
<div className="space-y-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-sm font-medium text-slate-900">{task.title}</span>
|
|
{task.priority ? <PriorityBadge priority={task.priority} /> : null}
|
|
{task.collection_id ? <Badge variant="secondary">{t('list.template', { id: task.collection_id })}</Badge> : null}
|
|
</div>
|
|
{task.description ? <p className="text-xs text-slate-500">{task.description}</p> : null}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={onEdit}>
|
|
<Pencil className="mr-1 h-4 w-4" />
|
|
{t('list.edit')}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={onDelete} className="text-slate-500 hover:text-rose-500">
|
|
<Trash2 className="mr-1 h-4 w-4" />
|
|
{t('list.delete')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PriorityBadge({ priority }: { priority: NonNullable<TaskPayload['priority']> }) {
|
|
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
|
|
const mapping: Record<NonNullable<TaskPayload['priority']>, { label: string; className: string }> = {
|
|
low: { label: t('priorities.low'), className: 'bg-emerald-50 text-emerald-600' },
|
|
medium: { label: t('priorities.medium'), className: 'bg-amber-50 text-amber-600' },
|
|
high: { label: t('priorities.high'), className: 'bg-rose-50 text-rose-600' },
|
|
urgent: { label: t('priorities.urgent'), className: 'bg-red-50 text-red-600' },
|
|
};
|
|
const { label, className } = mapping[priority];
|
|
return <Badge className={`border-none ${className}`}>{label}</Badge>;
|
|
}
|
|
|
|
function TasksSkeleton() {
|
|
return (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 4 }).map((_, index) => (
|
|
<div key={`task-skeleton-${index}`} className="h-16 animate-pulse rounded-xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
|
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary.empty' });
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-10 text-center">
|
|
<h3 className="text-base font-semibold text-slate-800">{t('title')}</h3>
|
|
<p className="text-sm text-slate-500">{t('description')}</p>
|
|
<Button onClick={onCreate} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
|
|
<Plus className="mr-1 h-4 w-4" />
|
|
{t('cta')}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|