feat: enhance team member task tracking and UI improvements

- Added functionality to build task counts by owner, integrating it into TeamDetailView and MemberCard components for better task management visibility.
- Updated MemberList to pass task counts to MemberCard, allowing for detailed task status display per member.
- Improved UI elements in MemberCard for better interaction and visual feedback, including task completion progress.
- Translated various UI text elements from Russian to English for consistency and accessibility.
This commit is contained in:
iliya 2026-02-23 12:33:01 +02:00 committed by Илия
parent 766dbb8541
commit c03ff54f50
6 changed files with 135 additions and 81 deletions

View file

@ -1,10 +1,11 @@
import React, { useEffect } from 'react';
import { TooltipProvider } from '@renderer/components/ui/tooltip';
import { ConfirmDialog } from './components/common/ConfirmDialog';
import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay';
import { ErrorBoundary } from './components/common/ErrorBoundary';
import { TabbedLayout } from './components/layout/TabbedLayout';
import { TooltipProvider } from './components/ui/tooltip';
import { useTheme } from './hooks/useTheme';
import { api } from './api';
import { initializeNotificationListeners, useStore } from './store';

View file

@ -5,6 +5,7 @@ import { Button } from '@renderer/components/ui/button';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -293,6 +294,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => {
setCreateTaskDialog({
open: true,
@ -487,6 +490,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<CollapsibleTeamSection title="Members" badge={data.members.length} defaultOpen>
<MemberList
members={data.members}
memberTaskCounts={memberTaskCounts}
isTeamAlive={data.isAlive}
onMemberClick={setSelectedMember}
onSendMessage={(member) => {
@ -625,7 +629,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
<input
type="search"
placeholder="Поиск..."
placeholder="Search..."
value={messagesSearchQuery}
onChange={(e) => setMessagesSearchQuery(e.target.value)}
onPointerDown={(e) => e.stopPropagation()}

View file

@ -4,11 +4,13 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import { ListPlus, MessageSquare } from 'lucide-react';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { ResolvedTeamMember } from '@shared/types';
interface MemberCardProps {
member: ResolvedTeamMember;
memberColor: string;
taskCounts?: TaskStatusCounts | null;
isTeamAlive?: boolean;
onClick?: () => void;
onSendMessage?: () => void;
@ -18,6 +20,7 @@ interface MemberCardProps {
export const MemberCard = ({
member,
memberColor,
taskCounts,
isTeamAlive,
onClick,
onSendMessage,
@ -26,85 +29,112 @@ export const MemberCard = ({
const dotClass = getMemberDotClass(member, isTeamAlive);
const presenceLabel = getPresenceLabel(member, isTeamAlive);
const colors = getTeamColorSet(memberColor);
const pending = taskCounts?.pending ?? 0;
const inProgress = taskCounts?.inProgress ?? 0;
const completed = taskCounts?.completed ?? 0;
const totalTasks = pending + inProgress + completed;
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
return (
<div
className="group relative flex cursor-pointer items-center gap-2.5 rounded px-2 py-1.5"
style={{
borderLeft: `3px solid ${colors.border}`,
backgroundColor: colors.badge,
}}
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
}}
>
<div className="pointer-events-none absolute inset-0 rounded transition-colors group-hover:bg-white/5" />
<div className="relative shrink-0">
<img
src={agentAvatarUrl(member.name)}
alt={member.name}
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<span
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
aria-label={member.status}
/>
</div>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text)]">
{member.name}
</span>
{(() => {
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
return roleLabel ? (
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
{roleLabel}
</span>
) : null;
})()}
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
<div className="rounded">
<div
className="group relative flex cursor-pointer items-center gap-2.5 rounded-t px-2 py-1.5"
style={{
borderLeft: `3px solid ${colors.border}`,
backgroundColor: colors.badge,
}}
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
}}
>
{presenceLabel}
</Badge>
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
>
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
</Badge>
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
title="Send Message"
onClick={(e) => {
e.stopPropagation();
onSendMessage?.();
}}
<div className="pointer-events-none absolute inset-0 rounded-t transition-colors group-hover:bg-white/5" />
<div className="relative shrink-0">
<img
src={agentAvatarUrl(member.name)}
alt={member.name}
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<span
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
aria-label={member.status}
/>
</div>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text)]">
{member.name}
</span>
{(() => {
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
return roleLabel ? (
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
{roleLabel}
</span>
) : null;
})()}
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
>
<MessageSquare size={13} />
</button>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
title="Assign Task"
onClick={(e) => {
e.stopPropagation();
onAssignTask?.();
}}
{presenceLabel}
</Badge>
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
>
<ListPlus size={13} />
</button>
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
</Badge>
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
title="Send Message"
onClick={(e) => {
e.stopPropagation();
onSendMessage?.();
}}
>
<MessageSquare size={13} />
</button>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
title="Assign Task"
onClick={(e) => {
e.stopPropagation();
onAssignTask?.();
}}
>
<ListPlus size={13} />
</button>
</div>
</div>
<div className="rounded-b border-x border-b border-t-0 border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1">
<div className="flex items-center gap-2">
<div
className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
role="progressbar"
aria-valuenow={completed}
aria-valuemin={0}
aria-valuemax={totalTasks}
aria-label={`Tasks ${completed}/${totalTasks} completed`}
>
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
style={{ width: `${Math.round(completedRatio * 100)}%` }}
/>
</div>
<span className="shrink-0 text-[10px] font-medium tabular-nums text-[var(--color-text-muted)]">
{completed}/{totalTasks}
</span>
</div>
</div>
</div>
);

View file

@ -2,10 +2,12 @@ import { getMemberColor } from '@shared/constants/memberColors';
import { MemberCard } from './MemberCard';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { ResolvedTeamMember } from '@shared/types';
interface MemberListProps {
members: ResolvedTeamMember[];
memberTaskCounts?: Map<string, TaskStatusCounts>;
isTeamAlive?: boolean;
onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void;
@ -14,6 +16,7 @@ interface MemberListProps {
export const MemberList = ({
members,
memberTaskCounts,
isTeamAlive,
onMemberClick,
onSendMessage,
@ -34,6 +37,7 @@ export const MemberList = ({
key={member.name}
member={member}
memberColor={member.color ?? getMemberColor(index)}
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
isTeamAlive={isTeamAlive}
onClick={() => onMemberClick?.(member)}
onSendMessage={() => onSendMessage?.(member)}

View file

@ -110,11 +110,11 @@ export const MessagesFilterPopover = ({
<PopoverContent align="end" className="w-72 p-0">
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Кто писал
From
</p>
<div className="max-h-40 space-y-1 overflow-y-auto">
{fromOptions.length === 0 ? (
<p className="text-xs italic text-[var(--color-text-muted)]">Нет данных</p>
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
) : (
fromOptions.map((name) => (
<label
@ -133,11 +133,11 @@ export const MessagesFilterPopover = ({
</div>
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Кому писали
To
</p>
<div className="max-h-40 space-y-1 overflow-y-auto">
{toOptions.length === 0 ? (
<p className="text-xs italic text-[var(--color-text-muted)]">Нет данных</p>
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
) : (
toOptions.map((name) => (
<label
@ -159,10 +159,10 @@ export const MessagesFilterPopover = ({
disabled={draftCount === 0}
onClick={handleReset}
>
Сбросить
Reset
</Button>
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={handleSave}>
Сохранить
Save
</Button>
</div>
</PopoverContent>

View file

@ -39,3 +39,18 @@ export function buildTaskCountsByTeam(tasks: GlobalTask[]): Map<string, TaskStat
}
return map;
}
/** Build a map of owner name (lowercase) -> task status counts (ignores deleted). */
export function buildTaskCountsByOwner(
tasks: { owner?: string | null; status: string }[]
): Map<string, TaskStatusCounts> {
const map = new Map<string, TaskStatusCounts>();
for (const task of tasks) {
const owner = task.owner?.trim();
if (!owner || task.status === 'deleted') continue;
const key = owner.toLowerCase();
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
map.set(key, incrementStatus(counts, task.status));
}
return map;
}