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:
parent
766dbb8541
commit
c03ff54f50
6 changed files with 135 additions and 81 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue