feat(tasks): enable sharing task lists with teams and users
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m6s

This commit is contained in:
sandrodach committed 2026-10-25 13:00:17 +01:00
1 parent cfc0477b5c
commit 0afa2f9c2d
7 files changed
+686 -173

No files matched your search

+274 -117
View File
@@ -16,8 +16,10 @@ import {
Share2,
Users,
CheckSquare,
User as UserIcon,
Lock,
} from 'lucide-react';
import { TaskList, Thought, Space, SubTask } from '../../lib/types';
import { TaskList, Thought, SubTask, Team, User, TaskListShare } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
const getInitials = (name?: string): string => {
@@ -45,7 +47,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
const [tasks, setTasks] = useState<Thought[]>([]);
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const [selectedListId, setSelectedListId] = useState<string>('all');
const [spaces, setSpaces] = useState<Space[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [collaborators, setCollaborators] = useState<User[]>([]);
const [discarded, setDiscarded] = useState<Thought[]>([]);
const [loading, setLoading] = useState(true);
@@ -57,12 +60,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
const [showNewListModal, setShowNewListModal] = useState(false);
const [newListTitle, setNewListTitle] = useState('');
const [newListIcon, setNewListIcon] = useState('📋');
const [newListSpaceId, setNewListSpaceId] = useState('');
const [newListShareTarget, setNewListShareTarget] = useState('');
// Share TaskList modal state
const [showShareListModal, setShowShareListModal] = useState(false);
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
const [shareTargetSpaceId, setShareTargetSpaceId] = useState('');
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
const [selectedShareTargetId, setSelectedShareTargetId] = useState('');
const [shareLoading, setShareLoading] = useState(false);
// Active notes inline edit ID
const [activeNotesTaskId, setActiveNotesTaskId] = useState<string | null>(null);
@@ -75,15 +80,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
const loadData = async () => {
setLoading(true);
try {
const [allTasks, allLists, allSpaces, allDiscarded] = await Promise.all([
const [allTasks, allLists, allTeams, allCollabs, allDiscarded] = await Promise.all([
api.getTasks(selectedListId === 'all' ? undefined : selectedListId).catch(() => []),
api.getTaskLists().catch(() => []),
api.getSpaces().catch(() => []),
api.getUserTeams().catch(() => []),
api.getCollaborators().catch(() => []),
api.getThoughts('discarded').catch(() => []),
]);
setTasks(Array.isArray(allTasks) ? allTasks : []);
setTaskLists(Array.isArray(allLists) ? allLists : []);
setSpaces(Array.isArray(allSpaces) ? allSpaces : []);
setTeams(Array.isArray(allTeams) ? allTeams : []);
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
} catch {
// Prevent unhandled promise rejection from crashing view
@@ -424,15 +431,27 @@ export const TasksView: React.FC<TasksViewProps> = ({
const title = newListTitle.trim();
if (!title) return;
try {
let teamId: string | undefined = undefined;
let userId: string | undefined = undefined;
if (newListShareTarget.startsWith('team:')) {
teamId = newListShareTarget.replace('team:', '');
} else if (newListShareTarget.startsWith('user:')) {
userId = newListShareTarget.replace('user:', '');
}
const created = await api.createTaskList({
title,
icon: newListIcon,
spaceId: newListSpaceId,
teamId: teamId,
});
setTaskLists((prev) => [...prev, created]);
if (userId && created?.id) {
await api.addTaskListShare(created.id, { targetType: 'user', targetId: userId });
}
const freshLists = await api.getTaskLists();
setTaskLists(freshLists);
setSelectedListId(created.id);
setShowNewListModal(false);
setNewListTitle('');
setNewListShareTarget('');
} catch (err: any) {
alert(err.message || 'Fehler beim Erstellen der Aufgabenliste');
}
@@ -456,25 +475,47 @@ export const TasksView: React.FC<TasksViewProps> = ({
const handleOpenShareModal = (tl: TaskList) => {
setSharingTaskList(tl);
setShareTargetSpaceId(tl.spaceId || '');
setSelectedShareTargetId('');
setShowShareListModal(true);
};
const handleSaveShareList = async () => {
if (!sharingTaskList) return;
const handleAddShare = async () => {
if (!sharingTaskList || !selectedShareTargetId) return;
setShareLoading(true);
triggerHaptic(15);
try {
const updated = await api.updateTaskList(sharingTaskList.id, {
spaceId: shareTargetSpaceId,
await api.addTaskListShare(sharingTaskList.id, {
targetType: addShareType,
targetId: selectedShareTargetId,
});
setTaskLists((prev) =>
prev.map((l) => (l.id === updated.id ? { ...l, ...updated } : l))
);
setShowShareListModal(false);
setSharingTaskList(null);
const freshLists = await api.getTaskLists();
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) setSharingTaskList(updated);
setSelectedShareTargetId('');
await loadData();
} catch (err: any) {
alert(err.message || 'Fehler beim Speichern der Freigabe');
alert(err.message || 'Fehler beim Hinzufügen der Freigabe');
} finally {
setShareLoading(false);
}
};
const handleRemoveShare = async (shareId: string) => {
if (!sharingTaskList) return;
setShareLoading(true);
triggerHaptic(15);
try {
await api.removeTaskListShare(sharingTaskList.id, shareId);
const freshLists = await api.getTaskLists();
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) setSharingTaskList(updated);
await loadData();
} catch (err: any) {
alert(err.message || 'Fehler beim Entfernen der Freigabe');
} finally {
setShareLoading(false);
}
};
@@ -491,7 +532,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
return unique;
}, [tasks]);
const safeTaskLists = Array.isArray(taskLists) ? taskLists : [];
const safeSpaces = Array.isArray(spaces) ? spaces : [];
const safeTeams = Array.isArray(teams) ? teams : [];
const safeCollaborators = Array.isArray(collaborators) ? collaborators : [];
const safeDiscarded = Array.isArray(discarded) ? discarded : [];
const openTasks = safeTasks.filter((t) => !t.isCompleted);
@@ -547,8 +589,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
</span>
))}
</div>
) : tl.spaceId ? (
<span title="Geteilt mit Team/Space">
) : (tl.teamId || (tl.shares && tl.shares.length > 0)) ? (
<span title="Geteilt mit Team oder Benutzern">
<Share2 className="w-2.5 h-2.5 text-accent ml-0.5" />
</span>
) : null}
@@ -594,7 +636,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<div
onClick={() => handleOpenShareModal(currentSelectedList)}
className="flex items-center -space-x-1.5 cursor-pointer hover:opacity-90 transition-opacity pl-0.5"
title={`Geteilt mit ${currentSelectedList.members.length} Mitgliedern (${currentSelectedList.spaceName || 'Team'}). Klick zum Verwalten.`}
title={`Geteilt mit ${currentSelectedList.members.length} Mitgliedern (${currentSelectedList.teamName || 'Team'}). Klick zum Verwalten.`}
>
{currentSelectedList.members.slice(0, 3).map((m) => (
<div
@@ -729,25 +771,36 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div>
</div>
{safeSpaces.length > 0 && (
<div>
<label className="block text-xs text-muted mb-1">
Geteilt mit Space (optional)
</label>
<select
value={newListSpaceId}
onChange={(e) => setNewListSpaceId(e.target.value)}
className="w-full h-8 input-field px-2 text-xs"
>
<option value="">Privat (nur ich)</option>
{safeSpaces.map((sp) => (
<option key={sp.id} value={sp.id}>
{sp.icon} {sp.name}
</option>
))}
</select>
</div>
)}
<div>
<label className="block text-xs text-muted mb-1">
Freigabe (optional)
</label>
<select
value={newListShareTarget}
onChange={(e) => setNewListShareTarget(e.target.value)}
className="w-full h-8 input-field px-2 text-xs"
>
<option value="">🔒 Privat (nur für mich)</option>
{safeTeams.length > 0 && (
<optgroup label="Teams & Gruppen">
{safeTeams.map((tm) => (
<option key={tm.id} value={`team:${tm.id}`}>
👥 {tm.name}
</option>
))}
</optgroup>
)}
{safeCollaborators.length > 0 && (
<optgroup label="Einzelne Benutzer">
{safeCollaborators.map((u) => (
<option key={u.id} value={`user:${u.id}`}>
👤 {u.displayName || u.email}
</option>
))}
</optgroup>
)}
</select>
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<button
@@ -771,10 +824,19 @@ export const TasksView: React.FC<TasksViewProps> = ({
<div className="fixed inset-0 modal-overlay z-50 flex items-center justify-center p-4">
<div className="w-full max-w-md panel-surface shadow-panel p-4 space-y-4">
<div className="flex items-center justify-between border-b border-border pb-2">
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
<Users className="w-4 h-4 text-accent" />
<span>Liste teilen: {sharingTaskList.title}</span>
</h3>
<div className="flex items-center gap-2">
<span className="text-base">{sharingTaskList.icon || '📋'}</span>
<div>
<h3 className="text-sm font-semibold text-main">
Liste teilen: {sharingTaskList.title}
</h3>
<p className="text-[11px] text-muted">
{sharingTaskList.shares && sharingTaskList.shares.length > 0
? `${sharingTaskList.shares.length} Freigabe(n) aktiv`
: 'Aktuell privat (nur für dich)'}
</p>
</div>
</div>
<button
type="button"
onClick={() => {
@@ -787,76 +849,178 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button>
</div>
<div className="space-y-3">
<div className="space-y-4">
{/* Active Shares */}
<div>
<label className="block text-xs text-muted mb-1 font-medium">
Freigeben für Bereich / Team:
<label className="block text-xs font-semibold text-main mb-1.5">
Aktive Freigaben:
</label>
<select
value={shareTargetSpaceId}
onChange={(e) => setShareTargetSpaceId(e.target.value)}
className="w-full h-8 px-2.5 input-field text-xs bg-panel"
>
<option value="">🔒 Privat (Nicht geteilt - nur für mich)</option>
{safeSpaces.map((s) => (
<option key={s.id} value={s.id}>
{s.icon} {s.name} ({s.members?.length || 1} Mitglieder)
</option>
))}
</select>
{sharingTaskList.shares && sharingTaskList.shares.length > 0 ? (
<div className="space-y-1.5 max-h-40 overflow-y-auto pr-1">
{sharingTaskList.shares.map((sh) => (
<div
key={sh.id}
className="p-2 bg-canvas border border-border rounded-card flex items-center justify-between text-xs"
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 rounded-pill bg-accent/20 text-accent flex items-center justify-center text-xs flex-shrink-0">
{sh.targetType === 'team' ? (
<Users className="w-3.5 h-3.5" />
) : (
<UserIcon className="w-3.5 h-3.5" />
)}
</div>
<div className="min-w-0">
<p className="font-semibold text-main truncate">
{sh.targetName || (sh.targetType === 'team' ? 'Team' : sh.email || 'Benutzer')}
</p>
{sh.email && sh.email !== sh.targetName && (
<p className="text-[10px] text-muted truncate">{sh.email}</p>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="badge-neutral text-[10px]">
{sh.targetType === 'team' ? 'Team' : 'Benutzer'}
</span>
{sharingTaskList.isOwner !== false && (
<button
type="button"
onClick={() => handleRemoveShare(sh.id)}
disabled={shareLoading}
className="text-muted hover:text-status-err p-1 transition-colors"
title="Freigabe entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
))}
</div>
) : (
<div className="p-2.5 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2">
<Lock className="w-3.5 h-3.5 text-muted flex-shrink-0" />
<span>Diese Liste ist privat. Füge unten ein Team oder einen Benutzer hinzu.</span>
</div>
)}
</div>
{/* Members preview */}
{(() => {
const selectedSpace = safeSpaces.find((s) => s.id === shareTargetSpaceId);
const members = selectedSpace?.members || [];
if (members.length > 0) {
return (
<div className="space-y-2 pt-2 border-t border-border">
<p className="text-xs font-semibold text-main">
Personen mit Zugriff ({members.length}):
</p>
<div className="max-h-48 overflow-y-auto space-y-1.5 pr-1">
{members.map((m) => (
<div
key={m.userId}
className="p-2 bg-canvas border border-border rounded-card flex items-center justify-between text-xs"
>
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-pill bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center">
{getInitials(m.displayName || m.email)}
</div>
<div>
<p className="font-semibold text-main">{m.displayName || m.email}</p>
<p className="text-[10px] text-muted">{m.email}</p>
</div>
</div>
<span className="badge-neutral text-[10px]">
{m.role === 'owner' ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Mitglied'}
</span>
{/* Add New Share */}
{sharingTaskList.isOwner !== false && (
<div className="space-y-2 pt-2 border-t border-border">
<label className="block text-xs font-semibold text-main">
Neue Freigabe hinzufügen:
</label>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => {
setAddShareType('team');
setSelectedShareTargetId('');
}}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
addShareType === 'team'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Users className="w-3 h-3 text-accent" />
<span>Team / Gruppe</span>
</button>
<button
type="button"
onClick={() => {
setAddShareType('user');
setSelectedShareTargetId('');
}}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
addShareType === 'user'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<UserIcon className="w-3 h-3 text-accent" />
<span>Einzelner Benutzer</span>
</button>
</div>
<div className="flex items-center gap-2">
{addShareType === 'team' ? (
<select
value={selectedShareTargetId}
onChange={(e) => setSelectedShareTargetId(e.target.value)}
className="flex-1 h-8 px-2.5 input-field text-xs bg-panel"
>
<option value="">-- Team / Gruppe auswählen --</option>
{safeTeams
.filter((t) => !(sharingTaskList.shares || []).some((s) => s.targetType === 'team' && s.targetId === t.id))
.map((t) => (
<option key={t.id} value={t.id}>
👥 {t.name} ({t.members?.length || 1} Mitglieder)
</option>
))}
</select>
) : (
<select
value={selectedShareTargetId}
onChange={(e) => setSelectedShareTargetId(e.target.value)}
className="flex-1 h-8 px-2.5 input-field text-xs bg-panel"
>
<option value="">-- Benutzer auswählen --</option>
{safeCollaborators
.filter((c) => !(sharingTaskList.shares || []).some((s) => s.targetType === 'user' && s.targetId === c.id))
.map((c) => (
<option key={c.id} value={c.id}>
👤 {c.displayName || c.email} ({c.email})
</option>
))}
</select>
)}
<button
type="button"
onClick={handleAddShare}
disabled={!selectedShareTargetId || shareLoading}
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-50"
>
<Plus className="w-3.5 h-3.5" />
<span>Freigeben</span>
</button>
</div>
</div>
)}
{/* Persons with access summary */}
{sharingTaskList.members && sharingTaskList.members.length > 0 && (
<div className="space-y-1.5 pt-2 border-t border-border">
<p className="text-xs font-semibold text-main">
Personen mit Zugriff ({sharingTaskList.members.length}):
</p>
<div className="max-h-36 overflow-y-auto space-y-1 pr-1">
{sharingTaskList.members.map((m) => (
<div
key={m.userId}
className="p-1.5 bg-canvas border border-border rounded-card flex items-center justify-between text-xs"
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-5 h-5 rounded-pill bg-accent/20 text-accent font-bold text-[9px] flex items-center justify-center flex-shrink-0">
{getInitials(m.displayName || m.email)}
</div>
))}
<span className="font-medium text-main truncate">
{m.displayName || m.email}
</span>
</div>
<span className="badge-neutral text-[10px] flex-shrink-0">
{m.role === 'owner' ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Mitglied'}
</span>
</div>
</div>
);
} else if (shareTargetSpaceId) {
return (
<p className="text-xs text-muted italic">Keine Mitglieder in diesem Bereich.</p>
);
} else {
return (
<div className="p-3 bg-canvas border border-border rounded-card text-xs text-muted space-y-1">
<p className="font-medium text-main">Diese Aufgabenliste ist privat.</p>
<p className="text-[11px] text-faint">
Wähle oben einen Familien- oder Team-Space aus, um die Liste und alle enthaltenen Aufgaben automatisch mit den Mitgliedern zu teilen.
</p>
</div>
);
}
})()}
))}
</div>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-border">
<div className="flex justify-end pt-2 border-t border-border">
<button
type="button"
onClick={() => {
@@ -865,14 +1029,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
}}
className="btn-action text-xs"
>
Abbrechen
</button>
<button
type="button"
onClick={handleSaveShareList}
className="btn-primary text-xs"
>
Freigabe speichern
Fertig
</button>
</div>
</div>