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>