INSIGHT-MVP/packages/frontend/src/shell/DashboardEmailTab.tsx
Thomas Reitz fbf0b33a1f feat(dashboard): E-Mail Lesefenster + Kalender Umbau
E-Mail Tab:
- Klick auf E-Mail öffnet Detail-Modal (Lesefenster wie Outlook)
- Modal zeigt: Betreff, Absender, Datum, Anhang-Info, Body-Vorschau
- CRM-Bereich: gefundener Kontakt mit "Im CRM öffnen" + "Als Aktivität"
  speichern; kein Kontakt → "Kontakt anlegen" navigiert zu /crm/contacts
- "In Outlook öffnen" Link im Footer des Modals

Kalender Tab:
- WeekView: nur Arbeitstage Mo–Fr (5-Spalten-Grid statt 7)
- Neue Ansicht "Agenda": 14-Tage-Listenansicht (eigener Toggle-Button)
- Tages-Agenda nur bei Monat- und Wochenansicht sichtbar (nicht Agenda-View)
- Home-Tab: Tages-Agenda des heutigen Tages als Widget rechts
  (nur sichtbar wenn M365 verbunden)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 11:36:30 +01:00

566 lines
19 KiB
TypeScript

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
useIntegrations,
useOffice365MailFolders,
useOffice365MailsInFolder,
useContactByEmail,
} from '../crm/hooks';
import { activitiesApi } from '../crm/api';
import type { M365Email, M365MailFolder, CrmContactLookup } from '../crm/types';
import styles from './DashboardEmailTab.module.css';
// ── Helpers ───────────────────────────────────────────────────────────────────
const DAYS_OPTIONS = [
{ label: '1 Tag', value: 1 },
{ label: '7 Tage', value: 7 },
{ label: '14 Tage', value: 14 },
{ label: 'Alle', value: 0 },
] as const;
// Priorität anhand bekannter DE/EN Ordnernamen (wellKnownName nicht verlässlich)
const FOLDER_NAME_PRIORITY: Record<string, number> = {
'posteingang': 0,
'inbox': 0,
'gesendete elemente': 1,
'sent items': 1,
'gesendet': 1,
'entwürfe': 2,
'drafts': 2,
'archiv': 3,
'archive': 3,
'junk-e-mail': 4,
'junk email': 4,
'spam': 4,
'gelöschte elemente': 5,
'deleted items': 5,
'papierkorb': 5,
'outbox': 6,
'ausgang': 6,
};
function isInboxFolder(f: M365MailFolder): boolean {
const name = f.displayName.toLowerCase();
return name === 'posteingang' || name === 'inbox' || f.wellKnownName === 'inbox';
}
function sortFolders(folders: M365MailFolder[]): M365MailFolder[] {
return [...folders].sort((a, b) => {
const pa = FOLDER_NAME_PRIORITY[a.displayName.toLowerCase()] ?? 99;
const pb = FOLDER_NAME_PRIORITY[b.displayName.toLowerCase()] ?? 99;
if (pa !== pb) return pa - pb;
return a.displayName.localeCompare(b.displayName, 'de');
});
}
function formatEmailDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
if (d.toDateString() === now.toDateString()) {
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
});
}
// ── EmailCard (Klick öffnet Detail-Modal) ────────────────────────────────────
interface EmailCardProps {
email: M365Email;
onClick: () => void;
}
function EmailCard({ email, onClick }: EmailCardProps) {
const senderEmail = email.from?.emailAddress?.address ?? null;
const { data: contactData } = useContactByEmail(senderEmail);
const contact = contactData?.data ?? null;
const displayName = email.from?.emailAddress?.name || senderEmail || '—';
return (
<div
role="button"
tabIndex={0}
className={`${styles.emailCard} ${!email.isRead ? styles.emailCardUnread : ''}`}
onClick={onClick}
onKeyDown={(e) => e.key === 'Enter' && onClick()}
>
<div className={styles.emailCardInner}>
<div className={styles.emailHeader}>
<span className={styles.emailSubject}>
{!email.isRead && <span className={styles.unreadDot} aria-hidden="true" />}
{email.subject || '(Kein Betreff)'}
</span>
<span className={styles.emailDate}>
{formatEmailDate(email.receivedDateTime)}
</span>
</div>
<div className={styles.emailMeta}>
<span className={styles.emailFrom}>{displayName}</span>
{contact && (
<span
className={styles.crmBadge}
title={`CRM-Kontakt: ${[contact.firstName, contact.lastName].filter(Boolean).join(' ')}`}
>
CRM
</span>
)}
{email.hasAttachments && (
<span className={styles.attachBadge} aria-label="Hat Anhang">📎</span>
)}
</div>
{email.bodyPreview && (
<p className={styles.emailPreview}>{email.bodyPreview}</p>
)}
</div>
</div>
);
}
// ── EmailDetailModal ──────────────────────────────────────────────────────────
interface EmailDetailModalProps {
email: M365Email;
onClose: () => void;
onSaveActivity: (email: M365Email, contact: CrmContactLookup) => void;
}
function EmailDetailModal({ email, onClose, onSaveActivity }: EmailDetailModalProps) {
const navigate = useNavigate();
const senderEmail = email.from?.emailAddress?.address ?? null;
const senderName = email.from?.emailAddress?.name ?? '';
const { data: contactData, isLoading: contactLoading } = useContactByEmail(senderEmail);
const contact = contactData?.data ?? null;
const contactName =
contact
? [contact.firstName, contact.lastName].filter(Boolean).join(' ') ||
contact.email ||
'—'
: null;
const handleOpenContact = () => {
onClose();
navigate(`/crm/contacts/${contact!.id}`);
};
const handleCreateContact = () => {
onClose();
navigate('/crm/contacts');
};
return (
<div className={styles.modalOverlay} onClick={onClose}>
<div className={styles.detailModal} onClick={(e) => e.stopPropagation()}>
{/* ── Kopfzeile ── */}
<div className={styles.detailHeader}>
<h2 className={styles.detailSubject}>
{email.subject || '(Kein Betreff)'}
</h2>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
aria-label="Schließen"
>
</button>
</div>
{/* ── Meta ── */}
<div className={styles.detailMeta}>
<div className={styles.detailMetaRow}>
<span className={styles.detailMetaLabel}>Von</span>
<span className={styles.detailMetaValue}>
{senderName || senderEmail || '—'}
{senderName && senderEmail && (
<span className={styles.detailSenderEmail}> &lt;{senderEmail}&gt;</span>
)}
</span>
</div>
<div className={styles.detailMetaRow}>
<span className={styles.detailMetaLabel}>Datum</span>
<span className={styles.detailMetaValue}>
{new Date(email.receivedDateTime).toLocaleString('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
{email.hasAttachments && (
<div className={styles.detailMetaRow}>
<span className={styles.detailMetaLabel}>Anhang</span>
<span className={styles.detailMetaValue}>📎 Vorhanden</span>
</div>
)}
</div>
{/* ── Body-Vorschau ── */}
<div className={styles.detailBody}>
{email.bodyPreview || (
<em className={styles.detailBodyEmpty}>Kein Vorschautext verfügbar.</em>
)}
</div>
{/* ── CRM-Bereich ── */}
<div className={styles.detailCrm}>
<span className={styles.detailCrmTitle}>CRM</span>
{contactLoading ? (
<span className={styles.detailCrmLoading}>Wird geprüft</span>
) : contact ? (
<div className={styles.detailCrmFound}>
<div className={styles.detailCrmInfo}>
<span className={styles.detailCrmAvatar}>👤</span>
<div>
<span className={styles.detailCrmName}>{contactName}</span>
{contact.companyName && (
<span className={styles.detailCrmCompany}> · {contact.companyName}</span>
)}
</div>
</div>
<div className={styles.detailCrmActions}>
<button
type="button"
className={styles.detailCrmOpenBtn}
onClick={handleOpenContact}
>
Im CRM öffnen
</button>
<button
type="button"
className={styles.saveBtn}
onClick={() => { onClose(); onSaveActivity(email, contact); }}
>
Als Aktivität speichern
</button>
</div>
</div>
) : (
<div className={styles.detailCrmMissing}>
<span className={styles.detailCrmMissingText}>
{senderEmail
? `Kein CRM-Kontakt für ${senderEmail}`
: 'Kein Absender bekannt'}
</span>
{senderEmail && (
<button
type="button"
className={styles.detailCrmCreateBtn}
onClick={handleCreateContact}
>
+ Kontakt anlegen
</button>
)}
</div>
)}
</div>
{/* ── Footer ── */}
<div className={styles.detailFooter}>
<a
href={email.webLink}
target="_blank"
rel="noopener noreferrer"
className={styles.outlookBtn}
>
In Outlook öffnen
</a>
</div>
</div>
</div>
);
}
// ── ActivityModal ─────────────────────────────────────────────────────────────
interface ActivityModalProps {
email: M365Email;
contact: CrmContactLookup;
onClose: () => void;
onSaved: () => void;
}
function ActivityModal({ email, contact, onClose, onSaved }: ActivityModalProps) {
const [comment, setComment] = useState('');
const qc = useQueryClient();
const contactName =
[contact.firstName, contact.lastName].filter(Boolean).join(' ') ||
contact.email ||
'—';
const { mutate, isPending, isError } = useMutation({
mutationFn: () =>
activitiesApi.create({
contactId: contact.id,
type: 'EMAIL',
subject: email.subject || '(Kein Betreff)',
description: comment.trim() || undefined,
completedAt: email.receivedDateTime,
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['crm', 'activities'] });
onSaved();
},
});
return (
<div className={styles.modalOverlay} onClick={onClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<h2 className={styles.modalTitle}>E-Mail als Aktivität speichern</h2>
<div className={styles.modalInfo}>
<div className={styles.modalInfoRow}>
<span className={styles.modalLabel}>Kontakt</span>
<span className={styles.modalValue}>{contactName}</span>
</div>
<div className={styles.modalInfoRow}>
<span className={styles.modalLabel}>Betreff</span>
<span className={styles.modalValue}>
{email.subject || '(Kein Betreff)'}
</span>
</div>
<div className={styles.modalInfoRow}>
<span className={styles.modalLabel}>Von</span>
<span className={styles.modalValue}>
{email.from?.emailAddress?.name ||
email.from?.emailAddress?.address ||
'—'}
</span>
</div>
</div>
<div className={styles.modalField}>
<label className={styles.modalLabel} htmlFor="dashboard-email-comment">
Kommentar (optional)
</label>
<textarea
id="dashboard-email-comment"
className={styles.modalTextarea}
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
placeholder="Notiz zur E-Mail eingeben…"
/>
</div>
{isError && (
<p className={styles.modalError}>
Fehler beim Speichern. Bitte versuchen Sie es erneut.
</p>
)}
<div className={styles.modalActions}>
<button type="button" className={styles.cancelBtn} onClick={onClose}>
Abbrechen
</button>
<button
type="button"
className={styles.saveBtn}
onClick={() => mutate()}
disabled={isPending}
>
{isPending ? 'Speichern…' : 'Als Aktivität speichern'}
</button>
</div>
</div>
</div>
);
}
// ── DashboardEmailTab ─────────────────────────────────────────────────────────
interface ActivityTarget {
email: M365Email;
contact: CrmContactLookup;
}
export function DashboardEmailTab() {
const { data: integrationsData, isLoading: integrationsLoading } = useIntegrations();
const isConnected =
integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false;
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [days, setDays] = useState<number>(7);
const [detailEmail, setDetailEmail] = useState<M365Email | null>(null);
const [activityTarget, setActivityTarget] = useState<ActivityTarget | null>(null);
const [lastSaved, setLastSaved] = useState<string | null>(null);
const { data: foldersData, isLoading: foldersLoading, isError: foldersError } =
useOffice365MailFolders();
const folders = foldersData?.data ?? [];
const sortedFolders = sortFolders(folders);
// Standardmäßig Posteingang — Graph API akzeptiert Well-Known-Namen direkt als ID,
// sodass E-Mails sofort geladen werden, bevor die Ordnerliste verfügbar ist.
const inboxFolder = sortedFolders.find(isInboxFolder);
const activeFolderId = selectedFolderId ?? inboxFolder?.id ?? 'inbox';
const { data: emailsData, isLoading: emailsLoading, error: emailsError } =
useOffice365MailsInFolder(activeFolderId, days);
const emails = emailsData?.data ?? [];
if (integrationsLoading) {
return <p className={styles.status}>Verbindung wird geprüft</p>;
}
if (!isConnected) {
return (
<div className={styles.notConnected}>
<span className={styles.notConnectedIcon}>📭</span>
<p className={styles.notConnectedTitle}>Microsoft 365 nicht verbunden</p>
<p className={styles.notConnectedSub}>
Verbinden Sie Ihr Konto unter{' '}
<a href="/crm/office365" className={styles.notConnectedLink}>
CRM Office 365
</a>
, um E-Mails anzuzeigen.
</p>
</div>
);
}
return (
<div className={styles.root}>
{/* Filter-Leiste */}
<div className={styles.filterBar}>
<span className={styles.filterLabel}>Zeitraum:</span>
{DAYS_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`${styles.filterBtn} ${days === opt.value ? styles.filterBtnActive : ''}`}
onClick={() => setDays(opt.value)}
>
{opt.label}
</button>
))}
</div>
{/* Erfolgs-Banner */}
{lastSaved && (
<div className={styles.successBanner}>
Aktivität für <strong>{lastSaved}</strong> wurde gespeichert.
</div>
)}
{/* Hauptlayout */}
<div className={styles.layout}>
{/* Ordner-Baum */}
<aside className={styles.folderTree}>
{foldersLoading && (
<p className={styles.status}>Ordner laden</p>
)}
{foldersError && !foldersLoading && (
<p className={styles.errorText} style={{ padding: '0.5rem 0.875rem', fontSize: '0.8125rem' }}>
Ordner konnten nicht geladen werden.
</p>
)}
{!foldersLoading && !foldersError && sortedFolders.length === 0 && (
<p className={styles.status} style={{ padding: '0.5rem 0.875rem', fontSize: '0.8125rem' }}>
Keine Ordner gefunden.
</p>
)}
{!foldersLoading && sortedFolders.length > 0 && (
<ul className={styles.folderList}>
{sortedFolders.map((folder) => (
<li key={folder.id}>
<button
type="button"
className={`${styles.folderItem} ${
activeFolderId === folder.id ||
(activeFolderId === 'inbox' && isInboxFolder(folder))
? styles.folderItemActive
: ''
}`}
onClick={() => {
setSelectedFolderId(folder.id);
setLastSaved(null);
}}
>
<span className={styles.folderName}>{folder.displayName}</span>
{folder.unreadItemCount > 0 && (
<span className={styles.folderBadge}>
{folder.unreadItemCount}
</span>
)}
</button>
</li>
))}
</ul>
)}
</aside>
{/* E-Mail-Liste */}
<section className={styles.emailList}>
{emailsLoading && (
<p className={styles.status}>E-Mails werden geladen</p>
)}
{emailsError && (
<p className={styles.errorText}>
E-Mails konnten nicht geladen werden.
</p>
)}
{!emailsLoading && !emailsError && emails.length === 0 && (
<p className={styles.status}>
Keine E-Mails im gewählten Zeitraum.
</p>
)}
{emails.map((email) => (
<EmailCard
key={email.id}
email={email}
onClick={() => {
setLastSaved(null);
setDetailEmail(email);
}}
/>
))}
</section>
</div>
{/* Detail-Modal */}
{detailEmail && (
<EmailDetailModal
email={detailEmail}
onClose={() => setDetailEmail(null)}
onSaveActivity={(e, contact) => {
setDetailEmail(null);
setLastSaved(null);
setActivityTarget({ email: e, contact });
}}
/>
)}
{/* Aktivität-Modal */}
{activityTarget && (
<ActivityModal
email={activityTarget.email}
contact={activityTarget.contact}
onClose={() => setActivityTarget(null)}
onSaved={() => {
const name =
[activityTarget.contact.firstName, activityTarget.contact.lastName]
.filter(Boolean)
.join(' ') || activityTarget.contact.email || 'Kontakt';
setLastSaved(name);
setActivityTarget(null);
}}
/>
)}
</div>
);
}