feat(dashboard): E-Mail Tab mit Outlook-Postfach, Ordner-Navigation und Aktivitäten-Speicherung

- GraphService: getMailFolders() + getMailsByFolder(folderId, days) Methoden
- Office365Controller: GET /crm/office365/folders und /folders/:id/messages?days=X Endpoints
- ContactsController/Service: GET /crm/contacts/lookup?email=xxx für CRM-Kontakt-Abgleich
- Frontend types: M365MailFolder + CrmContactLookup Interfaces
- Frontend API: office365Api.getMailFolders/getMailsInFolder + contactsApi.lookupByEmail
- Frontend Hooks: useOffice365MailFolders, useOffice365MailsInFolder, useContactByEmail
- DashboardEmailTab: Ordner-Sidebar, Zeitfilter (1/7/14 Tage/alle), E-Mail-Liste
  mit Outlook-Link, CRM-Badge bei bekannten Absendern, Aktivitäten-Modal mit Kommentar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-13 10:16:05 +01:00
parent f6dd072f23
commit 01dc8bb41c
10 changed files with 1071 additions and 6 deletions

View file

@ -11,6 +11,7 @@ import {
HttpCode,
HttpStatus,
UseGuards,
NotFoundException,
} from '@nestjs/common';
import {
ApiTags,
@ -71,6 +72,17 @@ export class ContactsController {
);
}
@Get('lookup')
@ApiOperation({ summary: 'Kontakt anhand E-Mail-Adresse suchen' })
async lookupByEmail(
@CurrentUser() user: JwtPayload,
@Query('email') email: string,
) {
const contact = await this.contactsService.findByEmail(user.tenantId!, email);
if (!contact) throw new NotFoundException('Kein Kontakt mit dieser E-Mail-Adresse gefunden');
return singleResponse(contact);
}
@Get(':id')
@ApiOperation({ summary: 'Kontakt-Details abrufen' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })

View file

@ -173,6 +173,26 @@ export class ContactsService {
return { data, total, page, pageSize };
}
/** Kontakt anhand einer E-Mail-Adresse suchen (erster Treffer) */
async findByEmail(tenantId: string, email: string) {
return this.prisma.contact.findFirst({
where: {
tenantId,
OR: [
{ email: { equals: email, mode: 'insensitive' } },
{ emails: { some: { email: { equals: email, mode: 'insensitive' } } } },
],
},
select: {
id: true,
firstName: true,
lastName: true,
email: true,
companyName: true,
},
});
}
async findOne(tenantId: string, id: string) {
const contact = await this.prisma.contact.findFirst({
where: { id, tenantId },

View file

@ -57,6 +57,15 @@ export interface M365Contact {
companyName: string | null;
}
export interface M365MailFolder {
id: string;
displayName: string;
totalItemCount: number;
unreadItemCount: number;
childFolderCount: number;
wellKnownName: string | null;
}
const GRAPH_BASE = 'https://graph.microsoft.com/v1.0';
const CACHE_TTL = 300; // 5 Minuten
@ -338,4 +347,67 @@ export class GraphService {
await this.redis.set(cacheKey, JSON.stringify(lists), CACHE_TTL);
return lists;
}
// ── Mail-Ordner ───────────────────────────────────────────────────────
/** Alle Mail-Ordner des Benutzers (Inbox, Gesendet, Entwürfe, …) */
async getMailFolders(userJwt: string, userId: string): Promise<M365MailFolder[]> {
const cacheKey = `graph:mail-folders:${userId}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached) as M365MailFolder[];
const accessToken = await this.getM365Token(userJwt);
const data = await this.graphGet<{ value: M365MailFolder[] }>(
accessToken,
'/me/mailFolders',
{
$top: '50',
$select: 'id,displayName,totalItemCount,unreadItemCount,childFolderCount,wellKnownName',
},
);
const folders = data.value ?? [];
await this.redis.set(cacheKey, JSON.stringify(folders), CACHE_TTL);
this.logger.debug(`Graph: ${folders.length} Mail-Ordner geladen`);
return folders;
}
/** E-Mails in einem bestimmten Ordner (mit optionalem Tages-Filter) */
async getMailsByFolder(
userJwt: string,
userId: string,
folderId: string,
days: number,
): Promise<M365Email[]> {
const cacheKey = `graph:folder-mails:${userId}:${folderId}:${days}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached) as M365Email[];
const accessToken = await this.getM365Token(userJwt);
const params: Record<string, string> = {
$top: '50',
$orderby: 'receivedDateTime desc',
$select: 'id,subject,bodyPreview,receivedDateTime,from,hasAttachments,isRead,webLink',
};
if (days > 0) {
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
params['$filter'] = `receivedDateTime ge ${since}`;
}
const data = await this.graphGet<{ value: M365Email[] }>(
accessToken,
`/me/mailFolders/${folderId}/messages`,
params,
);
const emails = data.value ?? [];
await this.redis.set(cacheKey, JSON.stringify(emails), CACHE_TTL);
this.logger.debug(
`Graph: ${emails.length} E-Mails in Ordner ${folderId} (days=${days}) geladen`,
);
return emails;
}
}

View file

@ -1,4 +1,4 @@
import { Controller, Get, Req, Logger } from '@nestjs/common';
import { Controller, Get, Param, Query, Req, Logger } from '@nestjs/common';
import { Request } from 'express';
import { GraphService } from './graph.service';
@ -18,6 +18,8 @@ interface JwtUser {
* GET /crm/office365/calendar Alle Kalendertermine (nächste 30 Tage)
* GET /crm/office365/contacts Alle Outlook-Kontakte
* GET /crm/office365/tasks Alle Aufgaben (Microsoft To Do)
* GET /crm/office365/folders Alle Mail-Ordner
* GET /crm/office365/folders/:folderId/messages E-Mails in einem Ordner (mit ?days=7)
*/
@Controller('office365')
export class Office365Controller {
@ -57,4 +59,28 @@ export class Office365Controller {
meta: { listCount: taskLists.length, taskCount: totalTasks },
};
}
@Get('folders')
async getMailFolders(@Req() req: Request & { user: JwtUser }) {
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
const folders = await this.graphService.getMailFolders(jwt, req.user.sub);
return { success: true, data: folders, meta: { count: folders.length } };
}
@Get('folders/:folderId/messages')
async getMailsInFolder(
@Req() req: Request & { user: JwtUser },
@Param('folderId') folderId: string,
@Query('days') days?: string,
) {
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
const daysNum = days ? parseInt(days, 10) : 7;
const emails = await this.graphService.getMailsByFolder(
jwt,
req.user.sub,
folderId,
isNaN(daysNum) ? 7 : daysNum,
);
return { success: true, data: emails, meta: { count: emails.length } };
}
}

View file

@ -5,6 +5,7 @@
import api from '../api/client';
import type {
Contact,
CrmContactLookup,
CreateContactPayload,
UpdateContactPayload,
ContactsQueryParams,
@ -74,6 +75,7 @@ import type {
M365CalendarEvent,
M365TaskList,
M365Contact,
M365MailFolder,
} from './types';
// --- Contacts ---
@ -103,6 +105,11 @@ export const contactsApi = {
api
.delete<SingleResponse<Contact>>(`/crm/contacts/${id}`)
.then((r) => r.data),
lookupByEmail: (email: string) =>
api
.get<SingleResponse<CrmContactLookup>>('/crm/contacts/lookup', { params: { email } })
.then((r) => r.data),
};
// --- Deals ---
@ -838,4 +845,19 @@ export const office365Api = {
'/crm/office365/tasks',
)
.then((r) => r.data),
getMailFolders: () =>
api
.get<{ success: boolean; data: M365MailFolder[]; meta: { count: number } }>(
'/crm/office365/folders',
)
.then((r) => r.data),
getMailsInFolder: (folderId: string, days: number) =>
api
.get<{ success: boolean; data: M365Email[]; meta: { count: number } }>(
`/crm/office365/folders/${folderId}/messages`,
{ params: { days } },
)
.then((r) => r.data),
};

View file

@ -1394,3 +1394,44 @@ export function useOffice365Tasks() {
staleTime: 5 * 60 * 1000,
});
}
export function useOffice365MailFolders() {
const { data: integrationsData } = useIntegrations();
const isConnected = integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false;
return useQuery({
queryKey: ['office365', 'folders'],
queryFn: () => office365Api.getMailFolders(),
enabled: isConnected,
staleTime: 10 * 60 * 1000,
});
}
export function useOffice365MailsInFolder(
folderId: string | null,
days: number,
) {
const { data: integrationsData } = useIntegrations();
const isConnected = integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false;
return useQuery({
queryKey: ['office365', 'folder-mails', folderId, days],
queryFn: () => office365Api.getMailsInFolder(folderId!, days),
enabled: isConnected && !!folderId,
staleTime: 2 * 60 * 1000,
});
}
export function useContactByEmail(email: string | null) {
return useQuery({
queryKey: ['crm', 'contacts', 'lookup', email],
queryFn: () => contactsApi.lookupByEmail(email!),
enabled: !!email,
staleTime: 10 * 60 * 1000,
retry: false,
});
}

View file

@ -1013,3 +1013,21 @@ export interface M365Contact {
jobTitle: string | null;
companyName: string | null;
}
export interface M365MailFolder {
id: string;
displayName: string;
totalItemCount: number;
unreadItemCount: number;
childFolderCount: number;
wellKnownName: string | null;
}
/** Minimaler CRM-Kontakt für E-Mail-Lookup */
export interface CrmContactLookup {
id: string;
firstName: string | null;
lastName: string | null;
email: string | null;
companyName: string | null;
}

View file

@ -0,0 +1,484 @@
/* ============================================================
DashboardEmailTab Outlook-Postfach im Dashboard
============================================================ */
.root {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ── Status / Leer-Zustände ── */
.status {
color: var(--color-text-muted);
font-size: 0.9375rem;
padding: 1rem 0;
}
.errorText {
color: #ef4444;
font-size: 0.9375rem;
padding: 1rem 0;
}
/* ── Nicht verbunden ── */
.notConnected {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
gap: 0.5rem;
}
.notConnectedIcon {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.notConnectedTitle {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text);
margin: 0;
}
.notConnectedSub {
font-size: 0.9375rem;
color: var(--color-text-muted);
margin: 0;
}
.notConnectedLink {
color: var(--color-primary);
text-decoration: none;
}
.notConnectedLink:hover {
text-decoration: underline;
}
/* ── Filter-Leiste ── */
.filterBar {
display: flex;
align-items: center;
gap: 0.375rem;
margin-bottom: 0.25rem;
}
.filterLabel {
font-size: 0.8125rem;
color: var(--color-text-muted);
margin-right: 0.25rem;
}
.filterBtn {
padding: 0.3125rem 0.875rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.15s;
}
.filterBtn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.filterBtnActive {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.filterBtnActive:hover {
color: #fff;
opacity: 0.9;
}
/* ── Erfolgs-Banner ── */
.successBanner {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: var(--radius-sm);
padding: 0.625rem 1rem;
font-size: 0.875rem;
color: #16a34a;
}
/* ── Hauptlayout ── */
.layout {
display: flex;
gap: 1rem;
align-items: flex-start;
min-height: 300px;
}
/* ── Ordner-Baum (Sidebar) ── */
.folderTree {
width: 200px;
flex-shrink: 0;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.5rem 0;
}
.folderList {
list-style: none;
margin: 0;
padding: 0;
}
.folderItem {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.875rem;
background: none;
border: none;
text-align: left;
font-size: 0.875rem;
color: var(--color-text-muted);
cursor: pointer;
transition: background 0.12s, color 0.12s;
gap: 0.375rem;
}
.folderItem:hover {
background: var(--color-bg);
color: var(--color-text);
}
.folderItemActive {
color: var(--color-primary);
font-weight: 600;
background: rgba(59, 130, 246, 0.07);
}
.folderItemActive:hover {
color: var(--color-primary);
background: rgba(59, 130, 246, 0.1);
}
.folderName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folderBadge {
font-size: 0.6875rem;
font-weight: 700;
background: var(--color-primary);
color: #fff;
border-radius: 999px;
padding: 0.0625rem 0.375rem;
min-width: 1.125rem;
text-align: center;
flex-shrink: 0;
}
/* ── E-Mail-Liste ── */
.emailList {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.375rem;
min-width: 0;
}
/* ── E-Mail-Karte ── */
.emailCard {
position: relative;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
transition: border-color 0.15s, box-shadow 0.15s;
overflow: hidden;
}
.emailCard:hover {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.08);
}
.emailCardUnread {
border-left: 3px solid var(--color-primary);
}
.emailLink {
display: block;
padding: 0.75rem 1rem;
text-decoration: none;
color: inherit;
padding-right: 6rem; /* Platz für den "Aktivität"-Button */
}
.emailHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.2rem;
}
.emailSubject {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.9375rem;
font-weight: 400;
color: var(--color-text);
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.emailCardUnread .emailSubject {
font-weight: 600;
}
.unreadDot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-primary);
flex-shrink: 0;
}
.emailDate {
font-size: 0.75rem;
color: var(--color-text-muted);
flex-shrink: 0;
}
.emailMeta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.2rem;
}
.emailFrom {
font-size: 0.8125rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.crmBadge {
font-size: 0.6875rem;
font-weight: 700;
background: rgba(34, 197, 94, 0.12);
color: #16a34a;
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 999px;
padding: 0.0625rem 0.375rem;
flex-shrink: 0;
white-space: nowrap;
}
.attachBadge {
font-size: 0.75rem;
flex-shrink: 0;
}
.emailPreview {
font-size: 0.8125rem;
color: var(--color-text-muted);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Aktivität-Button (absolut positioniert innerhalb der Karte) ── */
.saveActivityBtn {
position: absolute;
top: 50%;
right: 0.75rem;
transform: translateY(-50%);
padding: 0.3rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
background: var(--color-bg);
color: var(--color-primary);
border: 1px solid var(--color-primary);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
opacity: 0;
pointer-events: none;
}
.emailCard:hover .saveActivityBtn {
opacity: 1;
pointer-events: auto;
}
.saveActivityBtn:hover {
background: var(--color-primary);
color: #fff;
}
/* ── Aktivität-Modal ── */
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.75rem;
width: 100%;
max-width: 480px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
}
.modalTitle {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 1.25rem;
color: var(--color-text);
}
.modalInfo {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: var(--color-bg);
border-radius: var(--radius-sm);
padding: 0.875rem;
margin-bottom: 1.25rem;
}
.modalInfoRow {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.modalLabel {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-muted);
min-width: 64px;
flex-shrink: 0;
}
.modalValue {
font-size: 0.875rem;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modalField {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.modalField .modalLabel {
min-width: unset;
}
.modalTextarea {
width: 100%;
padding: 0.625rem 0.75rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
font-size: 0.875rem;
resize: vertical;
font-family: inherit;
box-sizing: border-box;
}
.modalTextarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.modalError {
font-size: 0.875rem;
color: #ef4444;
margin: 0 0 1rem;
}
.modalActions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.cancelBtn {
padding: 0.5rem 1.125rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
}
.cancelBtn:hover {
border-color: var(--color-text-muted);
color: var(--color-text);
}
.saveBtn {
padding: 0.5rem 1.25rem;
background: var(--color-primary);
border: none;
border-radius: var(--radius-sm);
color: #fff;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.saveBtn:hover:not(:disabled) {
opacity: 0.9;
}
.saveBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}

View file

@ -0,0 +1,369 @@
import { useState } from 'react';
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;
const FOLDER_PRIORITY: Record<string, number> = {
inbox: 0,
sentitems: 1,
drafts: 2,
archive: 3,
junkemail: 4,
deleteditems: 5,
};
function sortFolders(folders: M365MailFolder[]): M365MailFolder[] {
return [...folders].sort((a, b) => {
const pa = FOLDER_PRIORITY[a.wellKnownName ?? ''] ?? 99;
const pb = FOLDER_PRIORITY[b.wellKnownName ?? ''] ?? 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 ─────────────────────────────────────────────────────────────────
interface EmailCardProps {
email: M365Email;
onSaveActivity: (email: M365Email, contact: CrmContactLookup) => void;
}
function EmailCard({ email, onSaveActivity }: 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 className={`${styles.emailCard} ${!email.isRead ? styles.emailCardUnread : ''}`}>
<a
href={email.webLink}
target="_blank"
rel="noopener noreferrer"
className={styles.emailLink}
>
<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>
)}
</a>
{contact && (
<button
type="button"
className={styles.saveActivityBtn}
title={`Als Aktivität für ${[contact.firstName, contact.lastName].filter(Boolean).join(' ')} speichern`}
onClick={() => onSaveActivity(email, contact)}
>
+ Aktivität
</button>
)}
</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 [activityTarget, setActivityTarget] = useState<ActivityTarget | null>(null);
const [lastSaved, setLastSaved] = useState<string | null>(null);
const { data: foldersData, isLoading: foldersLoading } = useOffice365MailFolders();
const folders = foldersData?.data ?? [];
const sortedFolders = sortFolders(folders);
// Standardmäßig Posteingang
const inboxFolder =
sortedFolders.find((f) => f.wellKnownName === 'inbox') ??
sortedFolders[0] ??
null;
const activeFolderId = selectedFolderId ?? inboxFolder?.id ?? null;
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>
) : (
<ul className={styles.folderList}>
{sortedFolders.map((folder) => (
<li key={folder.id}>
<button
type="button"
className={`${styles.folderItem} ${
activeFolderId === folder.id ? 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}
onSaveActivity={(e, contact) => {
setLastSaved(null);
setActivityTarget({ email: e, contact });
}}
/>
))}
</section>
</div>
{/* 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>
);
}

View file

@ -2,6 +2,7 @@ import { useState } from 'react';
import { useAuth } from '../auth/AuthContext';
import { WeatherWidget } from '../components/WeatherWidget';
import { EventCountdownTiles } from '../components/EventCountdownTiles';
import { DashboardEmailTab } from './DashboardEmailTab';
import styles from './DashboardPage.module.css';
type DashboardTab = 'home' | 'emails' | 'calendar' | 'tasks' | 'contacts';
@ -85,7 +86,7 @@ export function DashboardPage() {
role={user?.role}
/>
)}
{activeTab === 'emails' && <ComingSoonTab label="E-Mail" />}
{activeTab === 'emails' && <DashboardEmailTab />}
{activeTab === 'calendar' && <ComingSoonTab label="Kalender" />}
{activeTab === 'tasks' && <ComingSoonTab label="Aufgaben" />}
{activeTab === 'contacts' && <ComingSoonTab label="Kontakte" />}