godcrm/panels/InboxPanel.tsx
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

486 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* InboxPanel — extracted from AIChatPanel.renderInboxPanel()
* Displays inbox conversations with search, agent filter, and date range filtering.
*/
import React from 'react';
import {
X,
Search,
Loader2,
User,
Users,
ChevronRight,
Inbox,
Filter,
Link2,
Pencil,
} from 'lucide-react';
import { cn } from '@/shared/utils/cn';
import { useAuthStore } from '@/features/auth/store/authStore';
import type { AIAgent } from '../types';
type PanelTab = 'none' | 'contacts' | 'ai-agents' | 'tasks' | 'settings' | 'inbox';
interface InboxConversation {
id: number;
title: string | null;
type: string;
agent_id?: number;
agent_name?: string | null;
agent_icon?: string | null;
unread_count: number;
updated_at: string;
participants: Array<{
user_id: number;
name: string;
email?: string;
avatar_url?: string;
}>;
sub_agents?: Array<{ row_id: number; name: string; icon?: string | null; response_mode?: string }>;
bound_table_id?: number | null;
bound_row_id?: number | null;
bound_row_title?: string | null;
bound_table_name?: string | null;
bound_table_icon?: string | null;
}
interface ChatPartner {
type: string;
id: number;
name: string;
avatarUrl?: string;
email?: string;
icon?: string | null;
participants?: Array<{ id: number; name: string; type: 'user' | 'agent' }>;
}
interface ChatParticipant {
id: number;
name: string;
type: 'user' | 'agent';
email?: string;
avatar?: string;
}
interface BoundRow {
table_id: number;
row_id: number;
table_name?: string;
table_icon?: string;
row_title?: string;
}
export interface InboxPanelProps {
inboxConversations: InboxConversation[];
safeAgents: AIAgent[];
totalUnreadCount: number;
showInboxFilters: boolean;
setShowInboxFilters: React.Dispatch<React.SetStateAction<boolean>>;
hasActiveInboxFilters: boolean;
inboxSearch: string;
setInboxSearch: (value: string) => void;
inboxAgentFilter: string;
setInboxAgentFilter: (value: string) => void;
inboxAgentOptions: Array<{ id: number; name: string; icon?: string | null }>;
inboxDateFrom: string;
setInboxDateFrom: (value: string) => void;
inboxDateTo: string;
setInboxDateTo: (value: string) => void;
isLoadingInbox: boolean;
inboxRenamingId: number | null;
setInboxRenamingId: (id: number | null) => void;
inboxRenamingTitle: string;
setInboxRenamingTitle: (title: string) => void;
inboxRenameInputRef: React.RefObject<HTMLInputElement>;
renameConversation: (id: number, title: string) => void;
userConversationId: number | null;
currentConversationId: number | null;
chatPartner: ChatPartner | null;
setChatPartner: (partner: ChatPartner | null) => void;
setChatParticipants: (participants: ChatParticipant[]) => void;
setChatMode: (mode: 'ai' | 'people') => void;
selectAgent: (agent: AIAgent) => void;
selectConversation: (id: number) => void;
setUserConversationId: (id: number | null) => void;
setBoundRows: (rows: BoundRow[]) => void;
setShowBoundRowsBar: (show: boolean) => void;
markAsReadMutation: { mutate: (id: number) => void };
setActivePanel: (panel: PanelTab) => void;
}
export function InboxPanel({
inboxConversations,
safeAgents,
totalUnreadCount,
showInboxFilters,
setShowInboxFilters,
hasActiveInboxFilters,
inboxSearch,
setInboxSearch,
inboxAgentFilter,
setInboxAgentFilter,
inboxAgentOptions,
inboxDateFrom,
setInboxDateFrom,
inboxDateTo,
setInboxDateTo,
isLoadingInbox,
inboxRenamingId,
setInboxRenamingId,
inboxRenamingTitle,
setInboxRenamingTitle,
inboxRenameInputRef,
renameConversation,
userConversationId,
currentConversationId,
chatPartner,
setChatPartner,
setChatParticipants,
setChatMode,
selectAgent,
selectConversation,
setUserConversationId,
setBoundRows,
setShowBoundRowsBar,
markAsReadMutation,
setActivePanel,
}: InboxPanelProps) {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-3 py-2 border-b border-[var(--border-secondary)] bg-[var(--bg-tertiary)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-[var(--text-secondary)]">
<Inbox className="w-3.5 h-3.5" />
<span>Все чаты</span>
</div>
<div className="flex items-center gap-1">
{totalUnreadCount > 0 && (
<span className="px-2 py-0.5 text-xs font-bold rounded-full bg-red-500 text-white">
{totalUnreadCount}
</span>
)}
<button
onClick={() => setShowInboxFilters(prev => !prev)}
className={cn(
"p-1 rounded transition-colors",
showInboxFilters || hasActiveInboxFilters
? "bg-[var(--color-primary-500)]/20 text-[var(--color-primary-500)]"
: "text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
)}
title="Фильтры"
>
<Filter className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
{/* Search bar — always visible */}
<div className="px-3 py-1.5 border-b border-[var(--border-secondary)]">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--text-tertiary)]" />
<input
type="text"
value={inboxSearch}
onChange={(e) => setInboxSearch(e.target.value)}
placeholder="Поиск чатов..."
className="w-full pl-8 pr-3 py-1.5 text-xs rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-primary)] text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary-500)]/30"
/>
{inboxSearch && (
<button
onClick={() => setInboxSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-[var(--bg-secondary)] rounded"
>
<X className="w-3 h-3 text-[var(--text-tertiary)]" />
</button>
)}
</div>
</div>
{/* Ticket #81444: Filter controls — collapsible */}
{showInboxFilters && (
<div className="px-3 py-2 border-b border-[var(--border-secondary)] space-y-2 bg-[var(--bg-secondary)]">
{/* Agent filter */}
<div>
<label className="text-[10px] text-[var(--text-tertiary)] mb-0.5 block">Агент</label>
<select
value={inboxAgentFilter}
onChange={(e) => setInboxAgentFilter(e.target.value)}
className="w-full px-2 py-1 text-xs rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary-500)]/30"
>
<option value="">Все агенты</option>
{inboxAgentOptions.map(agent => (
<option key={agent.id} value={agent.id}>
{agent.icon || '🤖'} {agent.name}
</option>
))}
</select>
</div>
{/* Date range */}
<div className="flex gap-2">
<div className="flex-1">
<label className="text-[10px] text-[var(--text-tertiary)] mb-0.5 block">От</label>
<input
type="date"
value={inboxDateFrom}
onChange={(e) => setInboxDateFrom(e.target.value)}
className="w-full px-2 py-1 text-xs rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary-500)]/30"
/>
</div>
<div className="flex-1">
<label className="text-[10px] text-[var(--text-tertiary)] mb-0.5 block">До</label>
<input
type="date"
value={inboxDateTo}
onChange={(e) => setInboxDateTo(e.target.value)}
className="w-full px-2 py-1 text-xs rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary-500)]/30"
/>
</div>
</div>
{/* Clear filters */}
{hasActiveInboxFilters && (
<button
onClick={() => {
setInboxSearch('');
setInboxAgentFilter('');
setInboxDateFrom('');
setInboxDateTo('');
}}
className="text-[10px] text-[var(--color-primary-500)] hover:underline"
>
Сбросить фильтры
</button>
)}
</div>
)}
{/* Conversation list */}
<div className="flex-1 overflow-y-auto">
{isLoadingInbox ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-[var(--text-tertiary)]" />
</div>
) : inboxConversations.length === 0 ? (
<div className="py-8 text-center text-sm text-[var(--text-tertiary)]">
{hasActiveInboxFilters ? 'Ничего не найдено' : 'Нет активных бесед'}
</div>
) : (
inboxConversations.map(conv => {
const currentUserId = useAuthStore.getState().user?.id;
const otherParticipants = conv.participants?.filter(p => currentUserId && p.user_id !== Number(currentUserId)) || [];
// For agent conversations with no other participants, show agent name from response or local cache
const agentForConv = conv.agent_id ? safeAgents.find(a => a.id === conv.agent_id) : null;
const agentName = conv.agent_name || agentForConv?.name || null;
const agentIcon = conv.agent_icon || agentForConv?.icon || null;
const displayName = conv.title || otherParticipants.map(p => p.name).join(', ') || agentName || 'Беседа';
return (
<button
key={conv.id}
onClick={() => {
if (otherParticipants.length === 1) {
const partner = otherParticipants[0];
setChatPartner({
type: 'user',
id: partner.user_id,
name: partner.name,
email: partner.email,
avatarUrl: partner.avatar_url
});
setChatParticipants([{ id: partner.user_id, name: partner.name, type: 'user' }]);
} else if (otherParticipants.length > 1) {
setChatPartner({
type: 'group',
id: conv.id,
name: displayName,
participants: otherParticipants.map(p => ({
id: p.user_id,
name: p.name,
type: 'user' as const
}))
});
setChatParticipants(otherParticipants.map(p => ({
id: p.user_id,
name: p.name,
type: 'user' as const
})));
} else {
// No other participants — self-chat or agent conversation
if (agentForConv) {
// Bug fix: Restore full agent context so sendMessage works
const partnerName = conv.title || agentForConv.name;
selectAgent(agentForConv);
setChatMode('ai');
setChatPartner({
type: 'agent',
id: agentForConv.id,
name: partnerName,
icon: agentForConv.icon,
});
setChatParticipants([]);
// Restore AI conversation context
selectConversation(conv.id);
} else if (conv.agent_id) {
// Agent conversation but agent not in local list — selectConversation
// will store pendingAgentId for deferred lookup when agents load
const partnerName = conv.title || conv.agent_name || 'AI Agent';
setChatMode('ai');
setChatPartner({
type: 'agent',
id: conv.agent_id,
name: partnerName,
icon: conv.agent_icon || undefined,
});
setChatParticipants([]);
selectConversation(conv.id);
} else {
const currentUser = useAuthStore.getState().user;
setChatPartner({
type: 'user',
id: Number(currentUser?.id || 0),
name: conv.title || currentUser?.name || 'Я',
});
setChatParticipants([{ id: Number(currentUser?.id || 0), name: currentUser?.name || 'Я', type: 'user' }]);
}
}
// Only set userConversationId for people chats; agent conversations use selectConversation
if (!conv.agent_id) {
setUserConversationId(conv.id);
}
// Bug fix: Restore bound rows from inbox conversation data (not just clear)
if (conv.bound_table_id && conv.bound_row_id) {
setBoundRows([{
table_id: conv.bound_table_id,
row_id: conv.bound_row_id,
table_name: conv.bound_table_name || undefined,
table_icon: conv.bound_table_icon || undefined,
row_title: conv.bound_row_title || undefined,
}]);
setShowBoundRowsBar(true);
} else {
setBoundRows([]);
setShowBoundRowsBar(false);
}
markAsReadMutation.mutate(conv.id);
setActivePanel('none');
}}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 hover:bg-[var(--bg-tertiary)] transition-colors text-left",
conv.unread_count > 0 && "bg-[var(--color-primary-500)]/5"
)}
>
<div className="relative">
<div className="w-10 h-10 rounded-full bg-[var(--bg-tertiary)] flex items-center justify-center text-sm">
{agentIcon || agentForConv ? (
<span className="text-lg">{agentIcon || agentForConv?.icon || '🤖'}</span>
) : conv.type === 'direct' ? (
<User className="w-5 h-5 text-[var(--text-tertiary)]" />
) : (
<Users className="w-5 h-5 text-[var(--text-tertiary)]" />
)}
</div>
{conv.unread_count > 0 && (
<span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-bold rounded-full bg-red-500 text-white flex items-center justify-center">
{conv.unread_count > 99 ? '99+' : conv.unread_count}
</span>
)}
</div>
<div className="flex-1 min-w-0">
{inboxRenamingId === conv.id ? (
<input
ref={inboxRenameInputRef}
type="text"
value={inboxRenamingTitle}
onChange={(e) => setInboxRenamingTitle(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') {
const newTitle = inboxRenamingTitle.trim();
if (newTitle) {
renameConversation(conv.id, newTitle);
// Bug fix: update chatPartner name if renaming the active conversation
const activeId = userConversationId || currentConversationId;
if (activeId === conv.id && chatPartner) {
setChatPartner({ ...chatPartner, name: newTitle });
}
}
setInboxRenamingId(null);
} else if (e.key === 'Escape') {
setInboxRenamingId(null);
}
}}
onBlur={() => {
const newTitle = inboxRenamingTitle.trim();
if (newTitle) {
renameConversation(conv.id, newTitle);
// Bug fix: update chatPartner name if renaming the active conversation
const activeId = userConversationId || currentConversationId;
if (activeId === conv.id && chatPartner) {
setChatPartner({ ...chatPartner, name: newTitle });
}
}
setInboxRenamingId(null);
}}
className="w-full px-1.5 py-0.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--color-primary-500)]/40 rounded text-[var(--text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary-500)]/50"
autoFocus
/>
) : (
<div className={cn(
"text-sm truncate flex items-center gap-1 group/inbox-title",
conv.unread_count > 0 ? "font-semibold text-[var(--text-primary)]" : "text-[var(--text-secondary)]"
)}>
<span
className="truncate"
onDoubleClick={(e) => {
e.stopPropagation();
e.preventDefault();
setInboxRenamingId(conv.id);
setInboxRenamingTitle(displayName);
setTimeout(() => inboxRenameInputRef.current?.select(), 50);
}}
>{displayName}</span>
<button
onClick={(e) => {
e.stopPropagation();
setInboxRenamingId(conv.id);
setInboxRenamingTitle(displayName);
setTimeout(() => inboxRenameInputRef.current?.select(), 50);
}}
className="flex-shrink-0 p-0.5 rounded opacity-0 group-hover/inbox-title:opacity-100 hover:bg-[var(--bg-secondary)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-all"
title="Переименовать"
>
<Pencil className="w-2.5 h-2.5" />
</button>
</div>
)}
<div className="text-[10px] text-[var(--text-tertiary)]">
{agentName ? `🤖 ${agentName}` : conv.type === 'direct' ? 'Личный чат' : `${conv.participants?.length || 0} участников`}
{' • '}
{new Date(conv.updated_at).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })}
</div>
{/* Ticket #81438: Show enriched bound row label */}
{conv.bound_row_id && (
<div className="flex items-center gap-1 text-[10px] text-blue-400/70 mt-0.5">
<Link2 className="w-2.5 h-2.5" />
<span className="truncate">
{conv.bound_table_icon || ''}{' '}
{conv.bound_table_name ? `${conv.bound_table_name}: ` : ''}
{conv.bound_row_title || `#${conv.bound_row_id}`}
</span>
</div>
)}
</div>
{conv.unread_count > 0 && (
<ChevronRight className="w-4 h-4 text-[var(--color-primary-500)]" />
)}
</button>
);
})
)}
</div>
</div>
);
}