mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 08:46:39 +02:00
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>
566 lines
19 KiB
TypeScript
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}> <{senderEmail}></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>
|
|
);
|
|
}
|