- Brand/Theming: Marketing-Farb- und Typographievariablen in `resources/css/app.css` eingeführt, AdminLayout, Dashboardkarten und Onboarding-Komponenten entsprechend angepasst; Dokumentation (`docs/todo/tenant-admin-onboarding-fusion.md`, `docs/changes/...`) aktualisiert. - Checkout & Payments: Checkout-, PayPal-Controller und Tests für integrierte Stripe/PayPal-Flows sowie Paket-Billing-Abläufe überarbeitet; neue PayPal SDK-Factory und Admin-API-Helper (`resources/js/admin/api.ts`) schaffen Grundlage für Billing/Members/Tasks-Seiten. - DX & Tests: Neue Playwright/E2E-Struktur (docs/testing/e2e.md, `tests/e2e/tenant-onboarding-flow.test.ts`, Utilities), E2E-Tenant-Seeder und zusätzliche Übersetzungen/Factories zur Unterstützung der neuen Flows. - Marketing-Kommunikation: Automatische Kontakt-Bestätigungsmail (`ContactConfirmation` + Blade-Template) implementiert; Guest-PWA unter `/event` erreichbar. - Nebensitzung: Blogsystem gefixt und umfassenden BlogPostSeeder für Beispielinhalte angelegt.
454 lines
15 KiB
TypeScript
454 lines
15 KiB
TypeScript
import React from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
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,
|
|
PaginationMeta,
|
|
TenantTask,
|
|
TaskPayload,
|
|
updateTask,
|
|
} from '../api';
|
|
import { isAuthError } from '../auth/tokens';
|
|
import { ADMIN_EVENTS_PATH } from '../constants';
|
|
|
|
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 default function TasksPage() {
|
|
const navigate = useNavigate();
|
|
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 [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, search: search.trim() || undefined })
|
|
.then((result) => {
|
|
if (cancelled) return;
|
|
setTasks(result.data);
|
|
setMeta(result.meta);
|
|
})
|
|
.catch((err) => {
|
|
if (!isAuthError(err)) {
|
|
setError('Tasks konnten nicht geladen werden.');
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [page, search]);
|
|
|
|
function openCreate() {
|
|
setEditingTask(null);
|
|
setForm(INITIAL_FORM);
|
|
setDialogOpen(true);
|
|
}
|
|
|
|
function openEdit(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);
|
|
}
|
|
|
|
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 loeschen?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await deleteTask(taskId);
|
|
setTasks((prev) => prev.filter((task) => task.id !== taskId));
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
setError('Task konnte nicht geloescht werden.');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function toggleCompletion(task: TenantTask) {
|
|
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.');
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<AdminLayout
|
|
title="Task Bibliothek"
|
|
subtitle="Weise Aufgaben zu und tracke Fortschritt rund um deine Events."
|
|
actions={
|
|
<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" />
|
|
Neuer Task
|
|
</Button>
|
|
}
|
|
>
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertTitle>Fehler</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
|
<CardHeader>
|
|
<CardTitle className="text-xl text-slate-900">Tasks verwalten</CardTitle>
|
|
<CardDescription className="text-sm text-slate-600">
|
|
Erstelle Aufgaben und ordne sie deinen Events zu.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<Input
|
|
placeholder="Nach Tasks suchen..."
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
className="sm:max-w-sm"
|
|
/>
|
|
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)}>
|
|
Events oeffnen
|
|
</Button>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<TaskSkeleton />
|
|
) : tasks.length === 0 ? (
|
|
<EmptyTasksState onCreate={openCreate} />
|
|
) : (
|
|
<div className="grid gap-4">
|
|
{tasks.map((task) => (
|
|
<TaskRow
|
|
key={task.id}
|
|
task={task}
|
|
onToggle={() => void toggleCompletion(task)}
|
|
onEdit={() => openEdit(task)}
|
|
onDelete={() => void handleDelete(task.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{meta && meta.last_page > 1 && (
|
|
<div className="flex items-center justify-between">
|
|
<Button
|
|
variant="outline"
|
|
disabled={page <= 1}
|
|
onClick={() => setPage((prev) => Math.max(prev - 1, 1))}
|
|
>
|
|
Zurueck
|
|
</Button>
|
|
<span className="text-xs text-slate-500">
|
|
Seite {meta.current_page} von {meta.last_page}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
disabled={page >= meta.last_page}
|
|
onClick={() => setPage((prev) => Math.min(prev + 1, meta.last_page))}
|
|
>
|
|
Weiter
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<TaskDialog
|
|
open={dialogOpen}
|
|
onOpenChange={setDialogOpen}
|
|
onSubmit={handleSubmit}
|
|
form={form}
|
|
setForm={setForm}
|
|
saving={saving}
|
|
isEditing={Boolean(editingTask)}
|
|
/>
|
|
</AdminLayout>
|
|
);
|
|
}
|
|
|
|
function TaskRow({
|
|
task,
|
|
onToggle,
|
|
onEdit,
|
|
onDelete,
|
|
}: {
|
|
task: TenantTask;
|
|
onToggle: () => void;
|
|
onEdit: () => void;
|
|
onDelete: () => void;
|
|
}) {
|
|
const assignedCount = task.assigned_events_count ?? task.assigned_events?.length ?? 0;
|
|
const completed = task.is_completed;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3 rounded-2xl border border-slate-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex flex-1 items-start gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
className="rounded-full border border-pink-200 bg-white/80 p-2 text-pink-600 shadow-sm transition hover:bg-pink-50"
|
|
>
|
|
{completed ? <CheckCircle2 className="h-5 w-5" /> : <Circle className="h-5 w-5" />}
|
|
</button>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<p className={`text-sm font-semibold ${completed ? 'text-slate-500 line-through' : 'text-slate-900'}`}>
|
|
{task.title}
|
|
</p>
|
|
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
|
{mapPriority(task.priority)}
|
|
</Badge>
|
|
</div>
|
|
{task.description && <p className="text-xs text-slate-600">{task.description}</p>}
|
|
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
|
{task.due_date && <span>Faellig: {formatDate(task.due_date)}</span>}
|
|
<span>Zugeordnet: {assignedCount}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={onEdit}>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={onDelete} className="text-rose-600">
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskDialog({
|
|
open,
|
|
onOpenChange,
|
|
onSubmit,
|
|
form,
|
|
setForm,
|
|
saving,
|
|
isEditing,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
|
form: TaskFormState;
|
|
setForm: React.Dispatch<React.SetStateAction<TaskFormState>>;
|
|
saving: boolean;
|
|
isEditing: boolean;
|
|
}) {
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>{isEditing ? 'Task bearbeiten' : 'Neuen Task anlegen'}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<form className="space-y-4" onSubmit={onSubmit}>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="task-title">Titel</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">Beschreibung</Label>
|
|
<textarea
|
|
id="task-description"
|
|
value={form.description}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
|
|
className="min-h-[80px] w-full rounded-md border border-slate-200 bg-white/80 p-2 text-sm shadow-sm focus:border-pink-300 focus:outline-none focus:ring-2 focus:ring-pink-200"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="task-priority">Prioritaet</Label>
|
|
<Select
|
|
value={form.priority ?? 'medium'}
|
|
onValueChange={(value) => setForm((prev) => ({ ...prev, priority: value as TaskPayload['priority'] }))}
|
|
>
|
|
<SelectTrigger id="task-priority">
|
|
<SelectValue placeholder="Prioritaet waehlen" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="low">Niedrig</SelectItem>
|
|
<SelectItem value="medium">Mittel</SelectItem>
|
|
<SelectItem value="high">Hoch</SelectItem>
|
|
<SelectItem value="urgent">Dringend</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="task-due-date">Faellig am</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-pink-100 bg-pink-50/60 p-3">
|
|
<div>
|
|
<Label htmlFor="task-completed" className="text-sm font-medium text-slate-800">
|
|
Bereits erledigt
|
|
</Label>
|
|
<p className="text-xs text-slate-500">Markiere Task als abgeschlossen.</p>
|
|
</div>
|
|
<Switch
|
|
id="task-completed"
|
|
checked={form.is_completed}
|
|
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_completed: Boolean(checked) }))}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button type="submit" disabled={saving}>
|
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Speichern'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function EmptyTasksState({ onCreate }: { onCreate: () => void }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-10 text-center">
|
|
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
|
|
<Plus className="h-5 w-5" />
|
|
</div>
|
|
<p className="text-sm text-slate-600">Noch keine Tasks angelegt. Lege deinen ersten Task an.</p>
|
|
<Button onClick={onCreate}>Task erstellen</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskSkeleton() {
|
|
return (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 4 }).map((_, index) => (
|
|
<div key={index} className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function mapPriority(priority: TenantTask['priority']): string {
|
|
switch (priority) {
|
|
case 'low':
|
|
return 'Niedrig';
|
|
case 'high':
|
|
return 'Hoch';
|
|
case 'urgent':
|
|
return 'Dringend';
|
|
default:
|
|
return 'Mittel';
|
|
}
|
|
}
|
|
|
|
function formatDate(value: string | null | undefined): string {
|
|
if (!value) return '--';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return '--';
|
|
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' });
|
|
}
|