feat(mail): resizable columns, account in folder tree, mail settings in CRM settings

- Add ResizablePanel component (components/ui/ResizablePanel.tsx) with
  drag-to-resize via mouse events, no external dependencies
- Replace fixed-width columns in Mail.tsx with ResizablePanel for all
  three panes (folder tree, mail list, mail detail)
- Remove account-select toolbar item from Mail.tsx; account selection
  is now integrated into the folder tree
- Restructure MailFolderTree to show mail accounts as top-level nodes
  with folders nested underneath, built from flat list via parent_id
- Add expand/collapse chevrons for accounts and folders with children
- Load folders for ALL accounts (not just selected one) so multi-account
  tree works
- Derive selectedAccountId from selected folder instead of separate
  account selector
- Add Mail tab to CRM Settings page (Settings.tsx) with 📧 icon
- Add /settings/mail route pointing to MailSettingsPage
- Update settings link in mail toolbar to point to /settings/mail
- Update no-accounts empty state link to /settings/mail
This commit is contained in:
Agent Zero
2026-07-15 13:37:22 +02:00
parent 5693fe1c3a
commit 0409a08002
5 changed files with 447 additions and 170 deletions
+151 -34
View File
@@ -1,22 +1,79 @@
/**
* Mail folder tree sidebar component.
* Shows hierarchical folder list with unread badges.
* Shows accounts as top-level nodes with their folders nested underneath.
* Folders come as a flat list with parent_id and are built into a tree.
*/
import React from 'react';
import React, { useState, useMemo } from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { MailFolder } from '@/api/mail';
import type { MailAccount, MailFolder } from '@/api/mail';
import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState';
export interface MailFolderTreeProps {
accounts: MailAccount[];
folders: MailFolder[];
selectedFolderId: string | null;
onSelect: (folderId: string) => void;
loading: boolean;
}
/**
* Build a hierarchical tree from a flat folder list for a given account.
*/
function buildFolderTree(folders: MailFolder[], accountId: string): MailFolder[] {
const accountFolders = folders.filter((f) => f.account_id === accountId);
const byId = new Map<string, MailFolder & { children: MailFolder[] }>();
for (const f of accountFolders) {
byId.set(f.id, { ...f, children: [] });
}
const roots: MailFolder[] = [];
for (const f of accountFolders) {
const node = byId.get(f.id)!;
if (f.parent_id && byId.has(f.parent_id)) {
byId.get(f.parent_id)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
function ChevronIcon({ expanded }: { expanded: boolean }) {
return (
<svg
className={clsx('w-3 h-3 transition-transform flex-shrink-0', expanded ? 'rotate-90' : '')}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
);
}
function FolderIcon() {
return (
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
);
}
function MailIcon() {
return (
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7-7 7 7M5 19h10a2 2 0 002-2V8l-5-5H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
);
}
function FolderNode({
folder,
selectedFolderId,
@@ -28,36 +85,51 @@ function FolderNode({
onSelect: (folderId: string) => void;
depth: number;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(true);
const isSelected = folder.id === selectedFolderId;
const hasChildren = folder.children && folder.children.length > 0;
return (
<div role="treeitem" aria-selected={isSelected} aria-label={folder.name}>
<button
onClick={() => onSelect(folder.id)}
className={clsx(
'w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isSelected
? 'bg-primary-50 text-primary-700 font-medium'
: 'hover:bg-secondary-50 text-secondary-700',
<div className="flex items-center" style={{ paddingLeft: `${depth * 12}px` }}>
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
className="flex-shrink-0 p-0.5 hover:bg-secondary-100 rounded"
aria-label={expanded ? 'Collapse' : 'Expand'}
data-testid={`folder-chevron-${folder.id}`}
>
<ChevronIcon expanded={expanded} />
</button>
) : (
<span className="w-4 flex-shrink-0" />
)}
data-testid={`folder-${folder.id}`}
>
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="flex-1 truncate text-left">{folder.name}</span>
{folder.unread_count > 0 && (
<Badge variant="primary" className="text-xs">{folder.unread_count}</Badge>
)}
{folder.total_count > 0 && folder.unread_count === 0 && (
<span className="text-xs text-secondary-400">{folder.total_count}</span>
)}
</button>
{hasChildren && (
<div className="ml-4" role="group">
<button
onClick={() => onSelect(folder.id)}
className={clsx(
'flex-1 flex items-center gap-2 px-2 py-1.5 rounded-md text-sm min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isSelected
? 'bg-primary-50 text-primary-700 font-medium'
: 'hover:bg-secondary-50 text-secondary-700',
)}
data-testid={`folder-${folder.id}`}
>
<FolderIcon />
<span className="flex-1 truncate text-left">{folder.name}</span>
{folder.unread_count > 0 && (
<Badge variant="primary" className="text-xs">{folder.unread_count}</Badge>
)}
{folder.total_count > 0 && folder.unread_count === 0 && (
<span className="text-xs text-secondary-400">{folder.total_count}</span>
)}
</button>
</div>
{hasChildren && expanded && (
<div role="group">
{folder.children!.map((child) => (
<FolderNode
key={child.id}
@@ -73,7 +145,52 @@ function FolderNode({
);
}
export function MailFolderTree({ folders, selectedFolderId, onSelect, loading }: MailFolderTreeProps) {
function AccountNode({
account,
folders,
selectedFolderId,
onSelect,
}: {
account: MailAccount;
folders: MailFolder[];
selectedFolderId: string | null;
onSelect: (folderId: string) => void;
}) {
const [expanded, setExpanded] = useState(true);
const accountFolders = useMemo(
() => buildFolderTree(folders, account.id),
[folders, account.id],
);
return (
<div role="treeitem" aria-label={account.email}>
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-1 px-1 py-1.5 rounded-md text-sm font-semibold text-secondary-900 hover:bg-secondary-50"
data-testid={`account-${account.id}`}
>
<ChevronIcon expanded={expanded} />
<MailIcon />
<span className="flex-1 truncate text-left">{account.email}</span>
</button>
{expanded && (
<div role="group" className="ml-2">
{accountFolders.map((folder) => (
<FolderNode
key={folder.id}
folder={folder}
selectedFolderId={selectedFolderId}
onSelect={onSelect}
depth={0}
/>
))}
</div>
)}
</div>
);
}
export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect, loading }: MailFolderTreeProps) {
const { t } = useTranslation();
if (loading) {
@@ -88,7 +205,7 @@ export function MailFolderTree({ folders, selectedFolderId, onSelect, loading }:
);
}
if (folders.length === 0) {
if (accounts.length === 0) {
return (
<EmptyState
title={t('mail.noFolders')}
@@ -100,13 +217,13 @@ export function MailFolderTree({ folders, selectedFolderId, onSelect, loading }:
return (
<div className="space-y-1" role="tree" data-testid="folder-tree-list">
{folders.map((folder) => (
<FolderNode
key={folder.id}
folder={folder}
{accounts.map((account) => (
<AccountNode
key={account.id}
account={account}
folders={folders}
selectedFolderId={selectedFolderId}
onSelect={onSelect}
depth={0}
/>
))}
</div>
@@ -0,0 +1,96 @@
/**
* ResizablePanel — a flex panel with drag-to-resize handle.
* No external dependencies; uses React + mouse events only.
*/
import React, { useState, useRef, useCallback, useEffect } from 'react';
import clsx from 'clsx';
export interface ResizablePanelProps {
/** Initial width in pixels (only used when resizable=true) */
initialWidth?: number;
/** Minimum width in pixels */
minWidth?: number;
/** Maximum width in pixels */
maxWidth?: number;
/** Panel content */
children: React.ReactNode;
/** Extra className for the panel container */
className?: string;
/** When false, panel becomes flex-1 with no drag handle */
resizable?: boolean;
/** testid forwarded to the container div */
'data-testid'?: string;
}
export function ResizablePanel({
initialWidth = 224,
minWidth = 150,
maxWidth = 600,
children,
className,
resizable = true,
...rest
}: ResizablePanelProps) {
const [width, setWidth] = useState(initialWidth);
const isResizing = useRef(false);
const startX = useRef(0);
const startWidth = useRef(0);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
isResizing.current = true;
startX.current = e.clientX;
startWidth.current = width;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
[width],
);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing.current) return;
const delta = e.clientX - startX.current;
const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth.current + delta));
setWidth(newWidth);
};
const handleMouseUp = () => {
if (isResizing.current) {
isResizing.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [minWidth, maxWidth]);
return (
<div
className={clsx(resizable ? 'relative flex-shrink-0' : 'relative flex-1', className)}
style={resizable ? { width: `${width}px` } : undefined}
data-testid={rest['data-testid']}
>
{children}
{resizable && (
<div
className="absolute top-0 right-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-primary-300 active:bg-primary-400 transition-colors z-10"
onMouseDown={handleMouseDown}
data-testid="resize-handle"
role="separator"
aria-orientation="vertical"
/>
)}
</div>
);
}