From 0409a08002c9327be85a2b519b0d8393d9b2241b Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 15 Jul 2026 13:37:22 +0200 Subject: [PATCH] feat(mail): resizable columns, account in folder tree, mail settings in CRM settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/components/mail/MailFolderTree.tsx | 185 ++++++++-- frontend/src/components/ui/ResizablePanel.tsx | 96 +++++ frontend/src/pages/Mail.tsx | 334 +++++++++++------- frontend/src/pages/Settings.tsx | 1 + frontend/src/routes/index.tsx | 1 + 5 files changed, 447 insertions(+), 170 deletions(-) create mode 100644 frontend/src/components/ui/ResizablePanel.tsx diff --git a/frontend/src/components/mail/MailFolderTree.tsx b/frontend/src/components/mail/MailFolderTree.tsx index c01a9a0..8ad25c6 100644 --- a/frontend/src/components/mail/MailFolderTree.tsx +++ b/frontend/src/components/mail/MailFolderTree.tsx @@ -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(); + + 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 ( + + ); +} + +function FolderIcon() { + return ( + + ); +} + +function MailIcon() { + return ( + + ); +} + 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 (
- + ) : ( + )} - data-testid={`folder-${folder.id}`} - > - - {folder.name} - {folder.unread_count > 0 && ( - {folder.unread_count} - )} - {folder.total_count > 0 && folder.unread_count === 0 && ( - {folder.total_count} - )} - - {hasChildren && ( -
+ +
+ {hasChildren && expanded && ( +
{folder.children!.map((child) => ( void; +}) { + const [expanded, setExpanded] = useState(true); + const accountFolders = useMemo( + () => buildFolderTree(folders, account.id), + [folders, account.id], + ); + + return ( +
+ + {expanded && ( +
+ {accountFolders.map((folder) => ( + + ))} +
+ )} +
+ ); +} + +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 ( - {folders.map((folder) => ( - ( + ))}
diff --git a/frontend/src/components/ui/ResizablePanel.tsx b/frontend/src/components/ui/ResizablePanel.tsx new file mode 100644 index 0000000..c175abd --- /dev/null +++ b/frontend/src/components/ui/ResizablePanel.tsx @@ -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 ( +
+ {children} + {resizable && ( +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Mail.tsx b/frontend/src/pages/Mail.tsx index 9125b6a..139ce1b 100644 --- a/frontend/src/pages/Mail.tsx +++ b/frontend/src/pages/Mail.tsx @@ -1,5 +1,7 @@ /** * Mail page — folder tree + mail list + reading pane + compose. + * Uses ResizablePanel for drag-to-resize columns. + * Account selection is integrated into the folder tree (left pane). */ import React, { useState, useEffect, useCallback } from 'react'; @@ -9,6 +11,7 @@ import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { useToast } from '@/components/ui/Toast'; import { EmptyState } from '@/components/ui/EmptyState'; +import { ResizablePanel } from '@/components/ui/ResizablePanel'; import { MailFolderTree } from '@/components/mail/MailFolderTree'; import { MailList } from '@/components/mail/MailList'; import { MailDetail } from '@/components/mail/MailDetail'; @@ -89,7 +92,7 @@ export function MailPage() { const sigs = await fetchSignatures(); setSignatures(sigs); } catch { - // non-critical + // non-critical } }, []); @@ -98,27 +101,35 @@ export function MailPage() { loadSignatures(); }, [loadAccounts, loadSignatures]); - // Load folders when account changes - const loadFolders = useCallback(async () => { - if (!selectedAccountId) return; + // Load folders for ALL accounts + const loadAllFolders = useCallback(async () => { + if (accounts.length === 0) return; setLoadingFolders(true); try { - const folderList = await fetchFolders(selectedAccountId); - setFolders(folderList); - // Auto-select first folder - if (folderList.length > 0 && !selectedFolderId) { - setSelectedFolderId(folderList[0].id); + const allFolders: MailFolder[] = []; + for (const acc of accounts) { + const folderList = await fetchFolders(acc.id); + allFolders.push(...folderList); } + setFolders(allFolders); } catch (err) { const msg = err instanceof Error ? err.message : String(err); setError(msg); } setLoadingFolders(false); - }, [selectedAccountId, selectedFolderId]); + }, [accounts]); useEffect(() => { - loadFolders(); - }, [loadFolders]); + loadAllFolders(); + }, [loadAllFolders]); + + // Auto-select first folder when folders are loaded + useEffect(() => { + if (folders.length > 0 && !selectedFolderId) { + setSelectedFolderId(folders[0].id); + setSelectedAccountId(folders[0].account_id); + } + }, [folders, selectedFolderId]); // Load mails when folder or page changes const loadMails = useCallback(async () => { @@ -146,32 +157,42 @@ export function MailPage() { loadMails(); }, [loadMails]); - // Handle folder selection - const handleSelectFolder = useCallback((folderId: string) => { - setSelectedFolderId(folderId); - setMailsPage(1); - setSearchQuery(''); - setSelectedMail(null); - }, []); + // Handle folder selection — derive account from folder + const handleSelectFolder = useCallback( + (folderId: string) => { + setSelectedFolderId(folderId); + const folder = folders.find((f) => f.id === folderId); + if (folder) { + setSelectedAccountId(folder.account_id); + } + setMailsPage(1); + setSearchQuery(''); + setSelectedMail(null); + }, + [folders], + ); // Handle mail selection - const handleSelectMail = useCallback(async (mail: Mail) => { - setLoadingMail(true); - try { - const detail = await getMail(mail.id); - setSelectedMail(detail); - // Mark as seen if not seen - if (!detail.is_seen) { - await updateFlags(mail.id, { seen: true }); - setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_seen: true } : m))); + const handleSelectMail = useCallback( + async (mail: Mail) => { + setLoadingMail(true); + try { + const detail = await getMail(mail.id); + setSelectedMail(detail); + // Mark as seen if not seen + if (!detail.is_seen) { + await updateFlags(mail.id, { seen: true }); + setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_seen: true } : m))); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + toast.error(msg); + setSelectedMail(mail); } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - toast.error(msg); - setSelectedMail(mail); - } - setLoadingMail(false); - }, [toast]); + setLoadingMail(false); + }, + [toast], + ); // Handle compose const handleCompose = useCallback(() => { @@ -198,72 +219,84 @@ export function MailPage() { }, []); // Handle toggle flag - const handleToggleFlag = useCallback(async (mail: Mail) => { - try { - await updateFlags(mail.id, { flagged: !mail.is_flagged }); - setSelectedMail((prev) => prev ? { ...prev, is_flagged: !mail.is_flagged } : prev); - setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_flagged: !mail.is_flagged } : m))); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } - }, [toast]); + const handleToggleFlag = useCallback( + async (mail: Mail) => { + try { + await updateFlags(mail.id, { flagged: !mail.is_flagged }); + setSelectedMail((prev) => (prev ? { ...prev, is_flagged: !mail.is_flagged } : prev)); + setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_flagged: !mail.is_flagged } : m))); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }, + [toast], + ); // Handle create event from mail - const handleCreateEvent = useCallback(async (mail: Mail) => { - const title = mail.subject || t('mail.noSubject'); - const now = new Date(); - const start = now.toISOString(); - const end = new Date(now.getTime() + 60 * 60 * 1000).toISOString(); - const payload: CreateEventFromMailPayload = { - title, - start, - end, - description: mail.body_text.slice(0, 500), - }; - try { - await createEventFromMail(mail.id, payload); - toast.success(t('mail.eventCreated')); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } - }, [toast, t]); + const handleCreateEvent = useCallback( + async (mail: Mail) => { + const title = mail.subject || t('mail.noSubject'); + const now = new Date(); + const start = now.toISOString(); + const end = new Date(now.getTime() + 60 * 60 * 1000).toISOString(); + const payload: CreateEventFromMailPayload = { + title, + start, + end, + description: mail.body_text.slice(0, 500), + }; + try { + await createEventFromMail(mail.id, payload); + toast.success(t('mail.eventCreated')); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }, + [toast, t], + ); // Handle attachment download - const handleDownloadAttachment = useCallback(async (mailId: string, attachment: MailAttachment) => { - setDownloadingAttachmentId(attachment.id); - try { - const blob = await downloadAttachment(mailId, attachment.id); - const url = window.URL.createObjectURL(blob); - const a = window.document.createElement('a'); - a.href = url; - a.download = attachment.filename; - window.document.body.appendChild(a); - a.click(); - window.document.body.removeChild(a); - window.URL.revokeObjectURL(url); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } finally { - setDownloadingAttachmentId(null); - } - }, [toast]); + const handleDownloadAttachment = useCallback( + async (mailId: string, attachment: MailAttachment) => { + setDownloadingAttachmentId(attachment.id); + try { + const blob = await downloadAttachment(mailId, attachment.id); + const url = window.URL.createObjectURL(blob); + const a = window.document.createElement('a'); + a.href = url; + a.download = attachment.filename; + window.document.body.appendChild(a); + a.click(); + window.document.body.removeChild(a); + window.URL.revokeObjectURL(url); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setDownloadingAttachmentId(null); + } + }, + [toast], + ); // Handle send (compose) - const handleSend = useCallback(async (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => { - try { - if (mode === 'reply' && replyToMail) { - await replyMail(replyToMail.id, payload as ReplyPayload); - } else if (mode === 'forward' && forwardMailState) { - await forwardMail(forwardMailState.id, payload as ForwardPayload); - } else { - await sendMail(payload as SendMailPayload); + const handleSend = useCallback( + async (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => { + try { + if (mode === 'reply' && replyToMail) { + await replyMail(replyToMail.id, payload as ReplyPayload); + } else if (mode === 'forward' && forwardMailState) { + await forwardMail(forwardMailState.id, payload as ForwardPayload); + } else { + await sendMail(payload as SendMailPayload); + } + toast.success(t('mail.sent')); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + throw err; } - toast.success(t('mail.sent')); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - throw err; - } - }, [replyToMail, forwardMailState, toast, t]); + }, + [replyToMail, forwardMailState, toast, t], + ); // Handle search const handleSearch = useCallback((query: string) => { @@ -271,17 +304,7 @@ export function MailPage() { setMailsPage(1); }, []); - // Handle account switch - const handleAccountSwitch = useCallback((accountId: string) => { - setSelectedAccountId(accountId); - setSelectedFolderId(null); - setSelectedMail(null); - setMails([]); - setMailsPage(1); - setSearchQuery(''); - }, []); - - // Register toolbar items + // Register toolbar items (account-select removed — account is now in folder tree) const registerItems = usePluginToolbarStore((s) => s.registerItems); const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin); const selectedMailId = selectedMail?.id; @@ -289,16 +312,6 @@ export function MailPage() { useEffect(() => { const hasMail = !!selectedMailId; registerItems('mail', [ - { - id: 'account-select', - plugin: 'mail', - label: 'Account', - type: 'select', - group: 'account', - selectValue: selectedAccountId, - selectOptions: accounts.map((a) => ({ value: a.id, label: a.email })), - onSelect: handleAccountSwitch, - }, { id: 'search', plugin: 'mail', @@ -307,13 +320,18 @@ export function MailPage() { group: 'search', searchPlaceholder: 'E-Mails durchsuchen...', onSearch: handleSearch, + onClick: () => {}, }, { id: 'compose', plugin: 'mail', label: 'Verfassen', group: 'compose', - icon: , + icon: ( + + + + ), onClick: handleCompose, }, { @@ -322,7 +340,11 @@ export function MailPage() { label: 'Antworten', group: 'mail-actions', disabled: !hasMail, - icon: , + icon: ( + + + + ), onClick: () => selectedMail && handleReply(selectedMail), }, { @@ -331,7 +353,11 @@ export function MailPage() { label: 'Weiterleiten', group: 'mail-actions', disabled: !hasMail, - icon: , + icon: ( + + + + ), onClick: () => selectedMail && handleForward(selectedMail), }, { @@ -341,7 +367,11 @@ export function MailPage() { group: 'mail-actions', disabled: !hasMail, active: selectedMailFlagged, - icon: , + icon: ( + + + + ), onClick: () => selectedMail && handleToggleFlag(selectedMail), }, { @@ -350,7 +380,11 @@ export function MailPage() { label: 'Termin', group: 'mail-actions', disabled: !hasMail, - icon: , + icon: ( + + + + ), onClick: () => selectedMail && handleCreateEvent(selectedMail), }, { @@ -358,7 +392,11 @@ export function MailPage() { plugin: 'mail', label: 'Sync', group: 'tools', - icon: , + icon: ( + + + + ), onClick: () => { /* sync trigger */ }, }, { @@ -366,12 +404,19 @@ export function MailPage() { plugin: 'mail', label: 'Einstellungen', group: 'tools', - icon: , - onClick: () => window.location.href = '/mail/settings', + icon: ( + + + + + ), + onClick: () => { + window.location.href = '/settings/mail'; + }, }, ]); return () => unregisterPlugin('mail'); - }, [accounts, selectedAccountId, selectedMailId, selectedMailFlagged, handleAccountSwitch, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent]); + }, [selectedMailId, selectedMailFlagged, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent]); if (loadingAccounts) { return ( @@ -391,7 +436,7 @@ export function MailPage() { {t('mail.configureAccount')}} + action={{t('mail.configureAccount')}} />
); @@ -405,23 +450,36 @@ export function MailPage() {
)} - {/* Three-pane layout */} + {/* Three-pane layout with resizable panels */}
- {/* Folder tree */} -
+ {/* Folder tree — resizable */} +

{t('mail.folders')}

-
+ - {/* Mail list */} -
+ {/* Mail list — resizable */} + -
+ - {/* Reading pane */} -
+ {/* Reading pane — flex-1, not resizable */} + -
+
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index fcdd652..279cdb9 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -14,6 +14,7 @@ export function SettingsPage() { { to: '/settings/currencies', label: t('currencies.title'), icon: '\ud83d\udcb0' }, { to: '/settings/taxes', label: t('taxes.title'), icon: '\ud83d\udccb' }, { to: '/settings/sequences', label: t('sequences.title'), icon: '\ud83d\udd22' }, + { to: '/settings/mail', label: t('mail.settings'), icon: '\ud83d\udce7' }, ]; return ( diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 83c23e6..eff3251 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -80,6 +80,7 @@ const router = createBrowserRouter([ { path: 'currencies', element: }, { path: 'taxes', element: }, { path: 'sequences', element: }, + { path: 'mail', element: }, ], }, ],