mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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:
parent
f6dd072f23
commit
01dc8bb41c
10 changed files with 1071 additions and 6 deletions
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
@ -14,10 +14,12 @@ interface JwtUser {
|
|||
* Zeigt alle M365-Daten des eingeloggten Users (ohne Kontakt-Filter).
|
||||
*
|
||||
* Routen:
|
||||
* GET /crm/office365/emails — Alle E-Mails (Posteingang)
|
||||
* 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/emails — Alle E-Mails (Posteingang)
|
||||
* 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 } };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
484
packages/frontend/src/shell/DashboardEmailTab.module.css
Normal file
484
packages/frontend/src/shell/DashboardEmailTab.module.css
Normal 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;
|
||||
}
|
||||
369
packages/frontend/src/shell/DashboardEmailTab.tsx
Normal file
369
packages/frontend/src/shell/DashboardEmailTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue