- Sync tasks to external provider when dragged to a synced Someday list (Google/Outlook/Synology) - Add Synology support to POST /api/tasks/sync endpoint - Edit recurring task series (title, interval, unit, time) all at once via new API - Make Someday and all-day areas vertically resizable with drag handles (height persisted in cookies) - Include Someday tasks in search results with list name display - Instant calendar event appearance after creation (improved optimistic update + immediate cache refresh) - Fix project color not updating on tasks when project color changes - Add database backup script for Supabase LXC v1.39.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
231 lines
14 KiB
TypeScript
231 lines
14 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import React, { useState } from 'react';
|
|
import { Repeat, X, Trash2, Pencil, Check } from 'lucide-react';
|
|
|
|
interface RecurringTasksManagerProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
tasks: any[];
|
|
onStopRecurring: (task: any) => void;
|
|
onSeriesUpdated?: () => void;
|
|
}
|
|
|
|
export default function RecurringTasksManager({ isOpen, onClose, tasks, onStopRecurring, onSeriesUpdated }: RecurringTasksManagerProps) {
|
|
const [editingSignature, setEditingSignature] = useState<string | null>(null);
|
|
const [editTitle, setEditTitle] = useState('');
|
|
const [editInterval, setEditInterval] = useState(1);
|
|
const [editUnit, setEditUnit] = useState('weeks');
|
|
const [editTime, setEditTime] = useState('');
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
// Filter and group recurring tasks by series signature
|
|
const seriesGroups = new Map<string, { task: any; signature: string }>();
|
|
|
|
tasks.filter(t => t.isRecurring).forEach(t => {
|
|
if (t.recurrenceEndDate && new Date(t.recurrenceEndDate) < new Date()) {
|
|
return;
|
|
}
|
|
|
|
const signature = `${t.title}-${t.recurrenceInterval || 1}-${t.recurrenceUnit || 'weeks'}-${t.recurrenceTime || ''}`;
|
|
|
|
const existing = seriesGroups.get(signature);
|
|
if (!existing || (t.scheduledDate && new Date(t.scheduledDate) > new Date(existing.task.scheduledDate || 0))) {
|
|
seriesGroups.set(signature, { task: t, signature });
|
|
}
|
|
});
|
|
|
|
const groupedTasks = Array.from(seriesGroups.values());
|
|
|
|
const startEditing = (task: any, signature: string) => {
|
|
setEditingSignature(signature);
|
|
setEditTitle(task.title);
|
|
setEditInterval(task.recurrenceInterval || 1);
|
|
setEditUnit(task.recurrenceUnit || 'weeks');
|
|
setEditTime(task.recurrenceTime || '');
|
|
};
|
|
|
|
const cancelEditing = () => {
|
|
setEditingSignature(null);
|
|
};
|
|
|
|
const saveEditing = async (task: any) => {
|
|
setIsSaving(true);
|
|
try {
|
|
const res = await fetch('/api/tasks/recurring', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
oldTitle: task.title,
|
|
oldRecurrenceInterval: task.recurrenceInterval || 1,
|
|
oldRecurrenceUnit: task.recurrenceUnit || 'weeks',
|
|
oldRecurrenceTime: task.recurrenceTime || '',
|
|
newTitle: editTitle,
|
|
newRecurrenceInterval: editInterval,
|
|
newRecurrenceUnit: editUnit,
|
|
newRecurrenceTime: editTime || null,
|
|
}),
|
|
});
|
|
|
|
if (res.ok) {
|
|
setEditingSignature(null);
|
|
onSeriesUpdated?.();
|
|
} else {
|
|
const err = await res.json();
|
|
alert(err.error || 'Failed to update series');
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to update recurring series:', e);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const unitLabel = (unit: string, interval: number) => {
|
|
if (unit === 'days') return interval === 1 ? 'Tag' : 'Tage';
|
|
if (unit === 'weeks') return interval === 1 ? 'Woche' : 'Wochen';
|
|
if (unit === 'months') return interval === 1 ? 'Monat' : 'Monate';
|
|
return interval === 1 ? 'Jahr' : 'Jahre';
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 z-[3000] flex items-center justify-center p-4 animate-fadeIn" onClick={onClose}>
|
|
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[600px] max-w-full overflow-hidden flex flex-col animate-scaleIn" onClick={e => e.stopPropagation()}>
|
|
<div className="p-5 flex items-center justify-between border-b border-gray-100 dark:border-gray-800">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-teal-100 dark:bg-teal-900/30 rounded-lg text-teal-600">
|
|
<Repeat size={20} />
|
|
</div>
|
|
<h2 className="text-lg font-bold text-gray-800 dark:text-white">Wiederkehrende Aufgaben</h2>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full text-gray-400 transition-colors">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
{groupedTasks.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<div className="w-16 h-16 bg-gray-50 dark:bg-gray-800 rounded-full flex items-center justify-center text-gray-300 mb-4">
|
|
<Repeat size={32} />
|
|
</div>
|
|
<p className="text-gray-500 dark:text-gray-400 max-w-xs">
|
|
Du hast momentan keine aktiven wiederkehrenden Aufgaben.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{groupedTasks.map(({ task, signature }) => (
|
|
<div key={signature} className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-100 dark:border-gray-700/50 hover:border-teal-200 dark:hover:border-teal-900/50 transition-all">
|
|
{editingSignature === signature ? (
|
|
/* Edit mode */
|
|
<div className="space-y-3">
|
|
<input
|
|
type="text"
|
|
value={editTitle}
|
|
onChange={e => setEditTitle(e.target.value)}
|
|
className="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm font-semibold outline-none focus:border-teal-400"
|
|
placeholder="Aufgabentitel"
|
|
/>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-xs text-gray-500">Alle</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={99}
|
|
value={editInterval}
|
|
onChange={e => setEditInterval(parseInt(e.target.value) || 1)}
|
|
className="w-14 px-2 py-1 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-xs text-center outline-none"
|
|
/>
|
|
<select
|
|
value={editUnit}
|
|
onChange={e => setEditUnit(e.target.value)}
|
|
className="px-2 py-1 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-xs outline-none"
|
|
>
|
|
<option value="days">Tage</option>
|
|
<option value="weeks">Wochen</option>
|
|
<option value="months">Monate</option>
|
|
<option value="years">Jahre</option>
|
|
</select>
|
|
<span className="text-xs text-gray-500">um</span>
|
|
<input
|
|
type="time"
|
|
value={editTime}
|
|
onChange={e => setEditTime(e.target.value)}
|
|
className="px-2 py-1 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-xs outline-none"
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={cancelEditing}
|
|
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onClick={() => saveEditing(task)}
|
|
disabled={isSaving || !editTitle.trim()}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-teal-500 hover:bg-teal-600 text-white rounded-lg text-xs font-bold transition-colors disabled:opacity-50"
|
|
>
|
|
<Check size={12} />
|
|
{isSaving ? 'Speichern...' : 'Alle aktualisieren'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* View mode */
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1 min-w-0 pr-4">
|
|
<div className="font-semibold text-gray-900 dark:text-white truncate" title={task.title}>
|
|
{task.title}
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<span className="text-xs px-2 py-0.5 bg-teal-50 dark:bg-teal-900/20 text-teal-600 dark:text-teal-400 rounded-md font-medium">
|
|
Alle {task.recurrenceInterval === 1 ? '' : `${task.recurrenceInterval} `}{unitLabel(task.recurrenceUnit || 'weeks', task.recurrenceInterval || 1)}
|
|
</span>
|
|
{task.recurrenceTime && (
|
|
<span className="text-[10px] text-gray-400">
|
|
um {task.recurrenceTime} Uhr
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => startEditing(task, signature)}
|
|
className="flex items-center gap-1.5 px-3 py-2 bg-blue-50 hover:bg-blue-100 dark:bg-blue-900/10 dark:hover:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg text-xs font-bold transition-colors"
|
|
>
|
|
<Pencil size={12} />
|
|
Bearbeiten
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
if (confirm(`Serie "${task.title}" wirklich beenden?`)) {
|
|
onStopRecurring(task);
|
|
}
|
|
}}
|
|
className="flex items-center gap-1.5 px-3 py-2 bg-red-50 hover:bg-red-100 dark:bg-red-900/10 dark:hover:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-xs font-bold transition-colors"
|
|
>
|
|
<Trash2 size={14} />
|
|
Beenden
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="p-3 bg-gray-50 dark:bg-gray-900 border-t border-gray-100 dark:border-gray-800 flex justify-center">
|
|
<p className="text-[10px] text-gray-400 font-medium">
|
|
Tipp: Du kannst Aufgaben jederzeit beim Bearbeiten wiederkehrend machen.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|