feat(tasks): assign tasks in shared lists, unified people search, list avatars
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m36s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m36s
- Tasks in shared task lists can be assigned to one or more people with access (task_assignees); assignments are pruned whenever access is lost - UserAutocomplete: type-to-search everywhere, nothing before typing, max 3 results, first match preselected - Task list and list tabs: avatar stack of members and round monochrome counter - Shared list items remember who added them (list_items.created_by) - Doku/ui-patterns.md documents the shared UI patterns Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1bd38f4d50
commit
73fad1c5db
21 files changed
+598
-67
No files matched your search
@@ -26,23 +26,14 @@ import {
|
||||
Filter,
|
||||
Webhook,
|
||||
} from 'lucide-react';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn, PriorityLevel } from '../../lib/types';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { TaskDetailModal } from './TaskDetailModal';
|
||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
const getInitials = (name?: string): string => {
|
||||
if (!name) return '?';
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
interface TasksViewProps {
|
||||
currentUser?: User | null;
|
||||
onRefreshStats: () => void;
|
||||
@@ -245,6 +236,42 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const isTaskReadOnly = (task: Thought) =>
|
||||
!!task.taskListId && safeTaskLists.find((l) => l.id === task.taskListId)?.permission === 'viewer';
|
||||
|
||||
// Assigning only makes sense in shared lists (someone besides the owner); members also filter out
|
||||
// assignees who lost access before the task payload caught up
|
||||
const membersByList = useMemo(() => {
|
||||
const m = new Map<string, TaskListMember[]>();
|
||||
for (const tl of safeTaskLists) m.set(tl.id, tl.members || []);
|
||||
return m;
|
||||
}, [safeTaskLists]);
|
||||
const assignableMembers = (task: Thought): TaskListMember[] => {
|
||||
const members = (task.taskListId && membersByList.get(task.taskListId)) || [];
|
||||
return members.length > 1 ? members : [];
|
||||
};
|
||||
const memberToPerson = (m: TaskListMember): AvatarPerson => ({
|
||||
id: m.userId,
|
||||
name: m.displayName || m.email,
|
||||
avatarUrl: m.avatarUrl,
|
||||
});
|
||||
const getAssigneePeople = (task: Thought): AvatarPerson[] => {
|
||||
const members = assignableMembers(task);
|
||||
return (task.assignees || [])
|
||||
.map((id) => members.find((m) => m.userId === id))
|
||||
.filter((m): m is TaskListMember => !!m)
|
||||
.map(memberToPerson);
|
||||
};
|
||||
const renderAssignees = (task: Thought, size: 'xxs' | 'xs') => {
|
||||
const people = getAssigneePeople(task);
|
||||
if (people.length === 0) return null;
|
||||
return (
|
||||
<AvatarStack
|
||||
people={people}
|
||||
size={size}
|
||||
ringClass="ring-[var(--bg-panel)]"
|
||||
title={`Zugewiesen: ${people.map((p) => p.name).join(', ')}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Active Kanban Columns (from selected list, or global custom, or default)
|
||||
const activeColumns: KanbanColumn[] = useMemo(() => {
|
||||
if (
|
||||
@@ -473,6 +500,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
await api.updateThought(task.id, { notes: newNotes });
|
||||
};
|
||||
|
||||
const handleUpdateTaskAssignees = async (task: Thought, assignees: string[]) => {
|
||||
triggerHaptic(10);
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t)));
|
||||
try {
|
||||
const updated = await api.updateThought(task.id, { assignees });
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: updated.assignees || [] } : t)));
|
||||
} catch {
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: task.assignees } : t)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTaskTags = async (task: Thought, newTags: string[]) => {
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, tags: newTags } : t)));
|
||||
await api.updateThought(task.id, { tags: newTags });
|
||||
@@ -1283,6 +1321,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderAssignees(task, 'xs')}
|
||||
|
||||
{/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
|
||||
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
@@ -1355,7 +1395,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<span>{tl.icon}</span>
|
||||
<span>{tl.title}</span>
|
||||
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
|
||||
<span className="ml-1 badge-neutral">{tl.tasksCount}</span>
|
||||
<span className="ml-1 badge-count">{tl.tasksCount}</span>
|
||||
)}
|
||||
{(() => {
|
||||
const isShared = (tl.shares && tl.shares.length > 0) || Boolean(tl.teamId) || (tl.members && tl.members.length > 1);
|
||||
@@ -1363,17 +1403,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
|
||||
if (otherMembers.length > 0) {
|
||||
return (
|
||||
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
|
||||
{otherMembers.slice(0, 2).map((m) => (
|
||||
<span key={m.userId} className="w-3.5 h-3.5 rounded-full overflow-hidden bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center shrink-0">
|
||||
{m.avatarUrl ? (
|
||||
<img src={m.avatarUrl} alt="" className="w-full h-full object-cover rounded-full" />
|
||||
) : (
|
||||
getInitials(m.displayName || m.email)[0]
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<AvatarStack
|
||||
people={otherMembers.map(memberToPerson)}
|
||||
size="xxs"
|
||||
ringClass={selectedListId === tl.id ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
|
||||
className="ml-0.5"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -2113,6 +2148,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onUpdateTaskList={handleUpdateTaskTaskList}
|
||||
onUpdateNotes={handleUpdateTaskNotes}
|
||||
onUpdateTags={handleUpdateTaskTags}
|
||||
assignableMembers={detailTask ? assignableMembers(detailTask) : []}
|
||||
onUpdateAssignees={handleUpdateTaskAssignees}
|
||||
onToggleSubtask={handleToggleSubtask}
|
||||
onAddSubtask={handleAddSubtask}
|
||||
onDeleteSubtask={handleDeleteSubtask}
|
||||
@@ -2315,6 +2352,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
#{tag}
|
||||
</button>
|
||||
))}
|
||||
{getAssigneePeople(task).length > 0 && (
|
||||
<span className="ml-auto">{renderAssignees(task, 'xxs')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column Navigation controls: Move Left / Move Right */}
|
||||
|
||||
Reference in new issue
Block a user