mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 22:36:38 +02:00
feat(crm): Office365-Übersichtsseite + Graph API Bugfixes
- Neuer Office365-Menüpunkt mit 4 Tabs (E-Mails, Kalender, Kontakte, Aufgaben) - CRM-Service: Office365Controller mit globalen Graph-Endpoints - Fix: $search + $orderby Kombination in Graph API nicht erlaubt - M365Contact Interface + attendees/hasAttachments Typen ergänzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
82e6a03bb9
commit
ad9c48bcb6
11 changed files with 1059 additions and 46 deletions
23
Summarize.md
23
Summarize.md
|
|
@ -1,11 +1,32 @@
|
|||
# INSIGHT MVP - Aenderungsprotokoll
|
||||
|
||||
## Stand: 2026-03-12
|
||||
## Stand: 2026-03-13
|
||||
|
||||
### Aktueller Sprint: CRM Phase 3 — Kanban-Board + Microsoft 365 OAuth-Integration (Feature-Branch: feature/crm-service)
|
||||
|
||||
---
|
||||
|
||||
### Aenderungen 2026-03-13: Office365-Seite + Graph API Bugfixes
|
||||
|
||||
#### Backend: CRM-Service — GraphModule erweitert
|
||||
- `graph/graph.service.ts` — Fix: `$search` + `$orderby` koennen nicht kombiniert werden (Graph API Limitation) → `$orderby` aus Kontakt-E-Mail-Suche entfernt; neue globale Methoden: `getAllEmails`, `getAllCalendarEvents`, `getAllOutlookContacts`, `getTasks` (war schon vorhanden); `attendees` zu Kalender-Abfragen ergaenzt; Fehler werden jetzt geloggt
|
||||
- `graph/office365.controller.ts` — Neuer Controller mit globalen Office365-Endpoints: `GET /crm/office365/emails`, `GET /crm/office365/calendar`, `GET /crm/office365/contacts`, `GET /crm/office365/tasks`
|
||||
- `graph/graph.module.ts` — `Office365Controller` registriert
|
||||
|
||||
#### Frontend: Office365-Uebersichtsseite
|
||||
- `crm/office365/Office365Page.tsx` — Neue Seite mit 4 Tabs: E-Mails, Kalender, Outlook-Kontakte, Aufgaben; zeigt alle M365-Daten des eingeloggten Users; Suchfilter fuer Kontakte; "CRM"-Button zum Navigieren in CRM-Kontakte
|
||||
- `crm/office365/Office365Page.module.css` — Vollstaendiges Styling (Cards, Tabs, Grid, Badges)
|
||||
- `crm/types.ts` — `M365Email.hasAttachments` und `M365CalendarEvent.attendees` ergaenzt; neues Interface `M365Contact`
|
||||
- `crm/api.ts` — `office365Api` (getEmails, getCalendar, getContacts, getTasks)
|
||||
- `crm/hooks.ts` — `useOffice365Emails`, `useOffice365Calendar`, `useOffice365Contacts`, `useOffice365Tasks`
|
||||
- `shell/App.tsx` — Route `/crm/office365` hinzugefuegt
|
||||
- `shell/AppLayout.tsx` — NavLink "Office 365" (Grid-Icon) nach Kanban ergaenzt
|
||||
|
||||
#### Datenbankfix
|
||||
- `user_integrations`-Tabelle: Tenant-Membership fuer `t.reitz@xinion.de` in "Xinion GmbH" manuell angelegt (fehlende Zuordnung verursachte 403 auf allen CRM-Endpoints)
|
||||
|
||||
---
|
||||
|
||||
### Aenderungen 2026-03-12: Microsoft 365 OAuth-Integration — Frontend
|
||||
|
||||
#### Frontend: MS365 Integration-Tab + Kontakt-Tabs
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GraphController } from './graph.controller';
|
||||
import { Office365Controller } from './office365.controller';
|
||||
import { GraphService } from './graph.service';
|
||||
import { CrmPrismaModule } from '../prisma/crm-prisma.module';
|
||||
import { RedisModule } from '../redis/redis.module';
|
||||
|
||||
@Module({
|
||||
imports: [CrmPrismaModule, RedisModule],
|
||||
controllers: [GraphController],
|
||||
controllers: [GraphController, Office365Controller],
|
||||
providers: [GraphService],
|
||||
})
|
||||
export class GraphModule {}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export interface M365CalendarEvent {
|
|||
end: { dateTime: string; timeZone: string };
|
||||
location: { displayName: string };
|
||||
organizer: { emailAddress: { name: string; address: string } };
|
||||
attendees: Array<{ emailAddress: { name: string; address: string }; type: string }>;
|
||||
isOnlineMeeting: boolean;
|
||||
onlineMeetingUrl: string | null;
|
||||
webLink: string;
|
||||
|
|
@ -46,6 +47,16 @@ export interface M365TaskList {
|
|||
tasks: M365Task[];
|
||||
}
|
||||
|
||||
export interface M365Contact {
|
||||
id: string;
|
||||
displayName: string;
|
||||
emailAddresses: Array<{ name: string; address: string }>;
|
||||
mobilePhone: string | null;
|
||||
businessPhones: string[];
|
||||
jobTitle: string | null;
|
||||
companyName: string | null;
|
||||
}
|
||||
|
||||
const GRAPH_BASE = 'https://graph.microsoft.com/v1.0';
|
||||
const CACHE_TTL = 300; // 5 Minuten
|
||||
|
||||
|
|
@ -64,10 +75,6 @@ export class GraphService {
|
|||
|
||||
// ── Token vom Core-Service holen ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* M365-Access-Token vom Core-Service abrufen.
|
||||
* Leitet den User-JWT an Core weiter — Core gibt den M365-Token zurueck.
|
||||
*/
|
||||
async getM365Token(userJwt: string): Promise<string> {
|
||||
const url = `${this.coreServiceUrl}/api/v1/users/me/integrations/microsoft-365/token`;
|
||||
|
||||
|
|
@ -133,6 +140,9 @@ export class GraphService {
|
|||
const errBody = (await resp.json().catch(() => ({}))) as {
|
||||
error?: { message?: string };
|
||||
};
|
||||
this.logger.error(
|
||||
`Graph API Fehler ${resp.status} auf ${path}: ${errBody.error?.message ?? resp.statusText}`,
|
||||
);
|
||||
throw new ServiceUnavailableException(
|
||||
`Graph API Fehler ${resp.status}: ${errBody.error?.message ?? resp.statusText}`,
|
||||
);
|
||||
|
|
@ -141,12 +151,8 @@ export class GraphService {
|
|||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── E-Mails ───────────────────────────────────────────────────────────
|
||||
// ── Kontakt-spezifische Abfragen ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* E-Mails zum Kontakt aus MS Graph laden.
|
||||
* Sucht in gesendeten + empfangenen Nachrichten nach der Kontakt-E-Mail.
|
||||
*/
|
||||
async getContactEmails(
|
||||
userJwt: string,
|
||||
userId: string,
|
||||
|
|
@ -154,19 +160,17 @@ export class GraphService {
|
|||
): Promise<M365Email[]> {
|
||||
const cacheKey = `graph:emails:${userId}:${contactEmail}`;
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached) as M365Email[];
|
||||
}
|
||||
if (cached) return JSON.parse(cached) as M365Email[];
|
||||
|
||||
const accessToken = await this.getM365Token(userJwt);
|
||||
|
||||
// Hinweis: $search und $orderby können NICHT kombiniert werden (Graph API Limitation)
|
||||
const data = await this.graphGet<{ value: M365Email[] }>(
|
||||
accessToken,
|
||||
'/me/messages',
|
||||
{
|
||||
$search: `"${contactEmail}"`,
|
||||
$top: '25',
|
||||
$orderby: 'receivedDateTime desc',
|
||||
$select:
|
||||
'id,subject,bodyPreview,receivedDateTime,from,hasAttachments,isRead,webLink',
|
||||
},
|
||||
|
|
@ -174,18 +178,10 @@ export class GraphService {
|
|||
|
||||
const emails = data.value ?? [];
|
||||
await this.redis.set(cacheKey, JSON.stringify(emails), CACHE_TTL);
|
||||
this.logger.debug(
|
||||
`Graph: ${emails.length} E-Mails fuer ${contactEmail} geladen`,
|
||||
);
|
||||
this.logger.debug(`Graph: ${emails.length} E-Mails fuer ${contactEmail} geladen`);
|
||||
return emails;
|
||||
}
|
||||
|
||||
// ── Kalender-Ereignisse ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Kalender-Ereignisse mit dem Kontakt aus MS Graph laden.
|
||||
* Filtert Ereignisse in denen die Kontakt-E-Mail als Teilnehmer vorkommt.
|
||||
*/
|
||||
async getContactCalendar(
|
||||
userJwt: string,
|
||||
userId: string,
|
||||
|
|
@ -193,17 +189,12 @@ export class GraphService {
|
|||
): Promise<M365CalendarEvent[]> {
|
||||
const cacheKey = `graph:calendar:${userId}:${contactEmail}`;
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached) as M365CalendarEvent[];
|
||||
}
|
||||
if (cached) return JSON.parse(cached) as M365CalendarEvent[];
|
||||
|
||||
const accessToken = await this.getM365Token(userJwt);
|
||||
|
||||
// Naechste 3 Monate
|
||||
const now = new Date().toISOString();
|
||||
const future = new Date(
|
||||
Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
const future = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const data = await this.graphGet<{ value: M365CalendarEvent[] }>(
|
||||
accessToken,
|
||||
|
|
@ -214,7 +205,7 @@ export class GraphService {
|
|||
$top: '20',
|
||||
$filter: `attendees/any(a:a/emailAddress/address eq '${contactEmail}')`,
|
||||
$select:
|
||||
'id,subject,start,end,location,organizer,isOnlineMeeting,onlineMeetingUrl,webLink',
|
||||
'id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeetingUrl,webLink',
|
||||
$orderby: 'start/dateTime asc',
|
||||
},
|
||||
);
|
||||
|
|
@ -224,25 +215,102 @@ export class GraphService {
|
|||
return events;
|
||||
}
|
||||
|
||||
// ── Aufgaben (Tasks) ──────────────────────────────────────────────────
|
||||
// ── Globale Office365-Übersicht ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Microsoft To Do Aufgaben laden.
|
||||
* Gibt alle Task-Listen mit ihren Aufgaben zurueck.
|
||||
*/
|
||||
async getTasks(
|
||||
userJwt: string,
|
||||
userId: string,
|
||||
): Promise<M365TaskList[]> {
|
||||
const cacheKey = `graph:tasks:${userId}`;
|
||||
/** Alle aktuellen E-Mails (Posteingang) */
|
||||
async getAllEmails(userJwt: string, userId: string): Promise<M365Email[]> {
|
||||
const cacheKey = `graph:all-emails:${userId}`;
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached) as M365TaskList[];
|
||||
}
|
||||
if (cached) return JSON.parse(cached) as M365Email[];
|
||||
|
||||
const accessToken = await this.getM365Token(userJwt);
|
||||
|
||||
const data = await this.graphGet<{ value: M365Email[] }>(
|
||||
accessToken,
|
||||
'/me/messages',
|
||||
{
|
||||
$top: '50',
|
||||
$orderby: 'receivedDateTime desc',
|
||||
$select:
|
||||
'id,subject,bodyPreview,receivedDateTime,from,hasAttachments,isRead,webLink',
|
||||
},
|
||||
);
|
||||
|
||||
const emails = data.value ?? [];
|
||||
await this.redis.set(cacheKey, JSON.stringify(emails), CACHE_TTL);
|
||||
this.logger.debug(`Graph: ${emails.length} E-Mails (global) geladen`);
|
||||
return emails;
|
||||
}
|
||||
|
||||
/** Alle Kalender-Ereignisse (nächste 30 Tage) */
|
||||
async getAllCalendarEvents(
|
||||
userJwt: string,
|
||||
userId: string,
|
||||
): Promise<M365CalendarEvent[]> {
|
||||
const cacheKey = `graph:all-calendar:${userId}`;
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached) return JSON.parse(cached) as M365CalendarEvent[];
|
||||
|
||||
const accessToken = await this.getM365Token(userJwt);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const data = await this.graphGet<{ value: M365CalendarEvent[] }>(
|
||||
accessToken,
|
||||
'/me/calendarView',
|
||||
{
|
||||
startDateTime: now,
|
||||
endDateTime: future,
|
||||
$top: '50',
|
||||
$select:
|
||||
'id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeetingUrl,webLink',
|
||||
$orderby: 'start/dateTime asc',
|
||||
},
|
||||
);
|
||||
|
||||
const events = data.value ?? [];
|
||||
await this.redis.set(cacheKey, JSON.stringify(events), CACHE_TTL);
|
||||
this.logger.debug(`Graph: ${events.length} Kalender-Ereignisse (global) geladen`);
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Outlook-Kontakte des Benutzers */
|
||||
async getAllOutlookContacts(
|
||||
userJwt: string,
|
||||
userId: string,
|
||||
): Promise<M365Contact[]> {
|
||||
const cacheKey = `graph:all-contacts:${userId}`;
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached) return JSON.parse(cached) as M365Contact[];
|
||||
|
||||
const accessToken = await this.getM365Token(userJwt);
|
||||
|
||||
const data = await this.graphGet<{ value: M365Contact[] }>(
|
||||
accessToken,
|
||||
'/me/contacts',
|
||||
{
|
||||
$top: '100',
|
||||
$orderby: 'displayName asc',
|
||||
$select:
|
||||
'id,displayName,emailAddresses,mobilePhone,businessPhones,jobTitle,companyName',
|
||||
},
|
||||
);
|
||||
|
||||
const contacts = data.value ?? [];
|
||||
await this.redis.set(cacheKey, JSON.stringify(contacts), CACHE_TTL);
|
||||
this.logger.debug(`Graph: ${contacts.length} Outlook-Kontakte geladen`);
|
||||
return contacts;
|
||||
}
|
||||
|
||||
/** Alle Task-Listen mit Aufgaben */
|
||||
async getTasks(userJwt: string, userId: string): Promise<M365TaskList[]> {
|
||||
const cacheKey = `graph:tasks:${userId}`;
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached) return JSON.parse(cached) as M365TaskList[];
|
||||
|
||||
const accessToken = await this.getM365Token(userJwt);
|
||||
|
||||
// Listen laden
|
||||
const listsData = await this.graphGet<{
|
||||
value: Array<{ id: string; displayName: string }>;
|
||||
}>(accessToken, '/me/todo/lists', { $top: '20' });
|
||||
|
|
|
|||
60
packages/crm-service/src/graph/office365.controller.ts
Normal file
60
packages/crm-service/src/graph/office365.controller.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { Controller, Get, Req, Logger } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { GraphService } from './graph.service';
|
||||
|
||||
interface JwtUser {
|
||||
sub: string;
|
||||
email: string;
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Office365Controller — Globale Microsoft 365 Übersicht
|
||||
*
|
||||
* 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)
|
||||
*/
|
||||
@Controller('office365')
|
||||
export class Office365Controller {
|
||||
private readonly logger = new Logger(Office365Controller.name);
|
||||
|
||||
constructor(private readonly graphService: GraphService) {}
|
||||
|
||||
@Get('emails')
|
||||
async getEmails(@Req() req: Request & { user: JwtUser }) {
|
||||
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
|
||||
const emails = await this.graphService.getAllEmails(jwt, req.user.sub);
|
||||
return { success: true, data: emails, meta: { count: emails.length } };
|
||||
}
|
||||
|
||||
@Get('calendar')
|
||||
async getCalendar(@Req() req: Request & { user: JwtUser }) {
|
||||
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
|
||||
const events = await this.graphService.getAllCalendarEvents(jwt, req.user.sub);
|
||||
return { success: true, data: events, meta: { count: events.length } };
|
||||
}
|
||||
|
||||
@Get('contacts')
|
||||
async getContacts(@Req() req: Request & { user: JwtUser }) {
|
||||
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
|
||||
const contacts = await this.graphService.getAllOutlookContacts(jwt, req.user.sub);
|
||||
return { success: true, data: contacts, meta: { count: contacts.length } };
|
||||
}
|
||||
|
||||
@Get('tasks')
|
||||
async getTasks(@Req() req: Request & { user: JwtUser }) {
|
||||
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
|
||||
const taskLists = await this.graphService.getTasks(jwt, req.user.sub);
|
||||
const totalTasks = taskLists.reduce((sum, l) => sum + l.tasks.length, 0);
|
||||
return {
|
||||
success: true,
|
||||
data: taskLists,
|
||||
meta: { listCount: taskLists.length, taskCount: totalTasks },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ import type {
|
|||
M365Email,
|
||||
M365CalendarEvent,
|
||||
M365TaskList,
|
||||
M365Contact,
|
||||
} from './types';
|
||||
|
||||
// --- Contacts ---
|
||||
|
|
@ -806,3 +807,35 @@ export const graphApi = {
|
|||
)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
// --- Office365 Übersicht (globale Daten) ---
|
||||
|
||||
export const office365Api = {
|
||||
getEmails: () =>
|
||||
api
|
||||
.get<{ success: boolean; data: M365Email[]; meta: { count: number } }>(
|
||||
'/crm/office365/emails',
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
getCalendar: () =>
|
||||
api
|
||||
.get<{ success: boolean; data: M365CalendarEvent[]; meta: { count: number } }>(
|
||||
'/crm/office365/calendar',
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
getContacts: () =>
|
||||
api
|
||||
.get<{ success: boolean; data: M365Contact[]; meta: { count: number } }>(
|
||||
'/crm/office365/contacts',
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
getTasks: () =>
|
||||
api
|
||||
.get<{ success: boolean; data: M365TaskList[]; meta: { listCount: number; taskCount: number } }>(
|
||||
'/crm/office365/tasks',
|
||||
)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
contractFilesApi,
|
||||
integrationsApi,
|
||||
graphApi,
|
||||
office365Api,
|
||||
} from './api';
|
||||
import type {
|
||||
ContactsQueryParams,
|
||||
|
|
@ -1335,3 +1336,61 @@ export function useContactTasks(contactId: string) {
|
|||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Office365 Übersicht ───────────────────────────────────────────────────────
|
||||
|
||||
export function useOffice365Emails() {
|
||||
const { data: integrationsData } = useIntegrations();
|
||||
const isConnected = integrationsData?.data?.some(
|
||||
(i) => i.provider === 'MICROSOFT_365' && i.connected,
|
||||
) ?? false;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['office365', 'emails'],
|
||||
queryFn: () => office365Api.getEmails(),
|
||||
enabled: isConnected,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOffice365Calendar() {
|
||||
const { data: integrationsData } = useIntegrations();
|
||||
const isConnected = integrationsData?.data?.some(
|
||||
(i) => i.provider === 'MICROSOFT_365' && i.connected,
|
||||
) ?? false;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['office365', 'calendar'],
|
||||
queryFn: () => office365Api.getCalendar(),
|
||||
enabled: isConnected,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOffice365Contacts() {
|
||||
const { data: integrationsData } = useIntegrations();
|
||||
const isConnected = integrationsData?.data?.some(
|
||||
(i) => i.provider === 'MICROSOFT_365' && i.connected,
|
||||
) ?? false;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['office365', 'contacts'],
|
||||
queryFn: () => office365Api.getContacts(),
|
||||
enabled: isConnected,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOffice365Tasks() {
|
||||
const { data: integrationsData } = useIntegrations();
|
||||
const isConnected = integrationsData?.data?.some(
|
||||
(i) => i.provider === 'MICROSOFT_365' && i.connected,
|
||||
) ?? false;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['office365', 'tasks'],
|
||||
queryFn: () => office365Api.getTasks(),
|
||||
enabled: isConnected,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
411
packages/frontend/src/crm/office365/Office365Page.module.css
Normal file
411
packages/frontend/src/crm/office365/Office365Page.module.css
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
/* ============================================================
|
||||
Office365Page — Microsoft 365 Übersicht
|
||||
============================================================ */
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.connectedBadge {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.disconnectedBadge {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ---- Connect Prompt ---- */
|
||||
|
||||
.connectPrompt {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.connectButton {
|
||||
margin-top: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.connectButton:hover { opacity: 0.9; }
|
||||
|
||||
/* ---- Tabs ---- */
|
||||
|
||||
.tabBar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--color-text); }
|
||||
|
||||
.activeTab {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* ---- States ---- */
|
||||
|
||||
.loadingText, .emptyText {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9375rem;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: #ef4444;
|
||||
font-size: 0.9375rem;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* ---- Shared list ---- */
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ---- Email Cards ---- */
|
||||
|
||||
.emailCard {
|
||||
display: block;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.875rem 1rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.emailCard:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.emailHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.emailSubject {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-text);
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.unread {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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.25rem;
|
||||
}
|
||||
|
||||
.emailFrom {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.attachmentBadge {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.emailPreview {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---- Calendar Cards ---- */
|
||||
|
||||
.calendarCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.875rem 1rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.calendarCard:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.calendarDate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.calendarDay {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.calendarTime {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.calendarContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendarSubject {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.calendarLocation, .calendarAttendees {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.onlineBadge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Contacts Grid ---- */
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.contactGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.contactCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.875rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.contactAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contactInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contactName {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.contactMeta {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.contactEmail {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.contactEmail:hover { text-decoration: underline; }
|
||||
|
||||
.importButton {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.importButton:hover {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ---- Task Cards ---- */
|
||||
|
||||
.taskCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.taskImportance {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
min-width: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.taskContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.taskTitle {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.taskMeta {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.taskStatus {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status_inProgress {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
322
packages/frontend/src/crm/office365/Office365Page.tsx
Normal file
322
packages/frontend/src/crm/office365/Office365Page.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
useIntegrations,
|
||||
useOffice365Emails,
|
||||
useOffice365Calendar,
|
||||
useOffice365Contacts,
|
||||
useOffice365Tasks,
|
||||
} from '../hooks';
|
||||
import { integrationsApi } from '../api';
|
||||
import type { M365Email, M365CalendarEvent, M365Contact, M365TaskList } from '../types';
|
||||
import styles from './Office365Page.module.css';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateOnly(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
type Tab = 'emails' | 'calendar' | 'contacts' | 'tasks';
|
||||
|
||||
const TAB_LABELS: { id: Tab; label: string }[] = [
|
||||
{ id: 'emails', label: 'E-Mails' },
|
||||
{ id: 'calendar', label: 'Kalender' },
|
||||
{ id: 'contacts', label: 'Kontakte' },
|
||||
{ id: 'tasks', label: 'Aufgaben' },
|
||||
];
|
||||
|
||||
// ── E-Mails ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function EmailsList() {
|
||||
const { data, isLoading, error } = useOffice365Emails();
|
||||
const emails: M365Email[] = data?.data ?? [];
|
||||
|
||||
if (isLoading) return <p className={styles.loadingText}>E-Mails werden geladen…</p>;
|
||||
if (error) return <p className={styles.errorText}>E-Mails konnten nicht geladen werden.</p>;
|
||||
if (!emails.length) return <p className={styles.emptyText}>Keine E-Mails gefunden.</p>;
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{emails.map((email) => (
|
||||
<a
|
||||
key={email.id}
|
||||
href={email.webLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.emailCard}
|
||||
>
|
||||
<div className={styles.emailHeader}>
|
||||
<span className={`${styles.emailSubject} ${!email.isRead ? styles.unread : ''}`}>
|
||||
{email.subject || '(Kein Betreff)'}
|
||||
</span>
|
||||
<span className={styles.emailDate}>
|
||||
{formatDate(email.receivedDateTime)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.emailMeta}>
|
||||
<span className={styles.emailFrom}>
|
||||
{email.from?.emailAddress?.name || email.from?.emailAddress?.address || '—'}
|
||||
</span>
|
||||
{email.hasAttachments && (
|
||||
<span className={styles.attachmentBadge}>📎</span>
|
||||
)}
|
||||
</div>
|
||||
{email.bodyPreview && (
|
||||
<p className={styles.emailPreview}>{email.bodyPreview}</p>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Kalender ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function CalendarList() {
|
||||
const { data, isLoading, error } = useOffice365Calendar();
|
||||
const events: M365CalendarEvent[] = data?.data ?? [];
|
||||
|
||||
if (isLoading) return <p className={styles.loadingText}>Termine werden geladen…</p>;
|
||||
if (error) return <p className={styles.errorText}>Kalendertermine konnten nicht geladen werden.</p>;
|
||||
if (!events.length) return <p className={styles.emptyText}>Keine Termine in den nächsten 30 Tagen.</p>;
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{events.map((event) => (
|
||||
<a
|
||||
key={event.id}
|
||||
href={event.webLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.calendarCard}
|
||||
>
|
||||
<div className={styles.calendarDate}>
|
||||
<span className={styles.calendarDay}>
|
||||
{new Date(event.start.dateTime).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short' })}
|
||||
</span>
|
||||
<span className={styles.calendarTime}>
|
||||
{new Date(event.start.dateTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' – '}
|
||||
{new Date(event.end.dateTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.calendarContent}>
|
||||
<span className={styles.calendarSubject}>{event.subject}</span>
|
||||
{event.location?.displayName && (
|
||||
<span className={styles.calendarLocation}>📍 {event.location.displayName}</span>
|
||||
)}
|
||||
{event.attendees && event.attendees.length > 0 && (
|
||||
<span className={styles.calendarAttendees}>
|
||||
{event.attendees.slice(0, 3).map((a) => a.emailAddress.name || a.emailAddress.address).join(', ')}
|
||||
{event.attendees.length > 3 && ` +${event.attendees.length - 3}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{event.isOnlineMeeting && (
|
||||
<span className={styles.onlineBadge}>Online</span>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Outlook-Kontakte ──────────────────────────────────────────────────────────
|
||||
|
||||
function ContactsList() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error } = useOffice365Contacts();
|
||||
const contacts: M365Contact[] = data?.data ?? [];
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
if (isLoading) return <p className={styles.loadingText}>Kontakte werden geladen…</p>;
|
||||
if (error) return <p className={styles.errorText}>Kontakte konnten nicht geladen werden.</p>;
|
||||
|
||||
const filtered = contacts.filter((c) => {
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
c.displayName.toLowerCase().includes(q) ||
|
||||
c.emailAddresses.some((e) => e.address?.toLowerCase().includes(q)) ||
|
||||
c.companyName?.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Kontakte durchsuchen…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
{!filtered.length ? (
|
||||
<p className={styles.emptyText}>Keine Kontakte gefunden.</p>
|
||||
) : (
|
||||
<div className={styles.contactGrid}>
|
||||
{filtered.map((contact) => {
|
||||
const primaryEmail = contact.emailAddresses[0]?.address;
|
||||
return (
|
||||
<div key={contact.id} className={styles.contactCard}>
|
||||
<div className={styles.contactAvatar}>
|
||||
{contact.displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className={styles.contactInfo}>
|
||||
<span className={styles.contactName}>{contact.displayName}</span>
|
||||
{contact.jobTitle && (
|
||||
<span className={styles.contactMeta}>{contact.jobTitle}</span>
|
||||
)}
|
||||
{contact.companyName && (
|
||||
<span className={styles.contactMeta}>{contact.companyName}</span>
|
||||
)}
|
||||
{primaryEmail && (
|
||||
<a href={`mailto:${primaryEmail}`} className={styles.contactEmail}>
|
||||
{primaryEmail}
|
||||
</a>
|
||||
)}
|
||||
{contact.mobilePhone && (
|
||||
<span className={styles.contactMeta}>{contact.mobilePhone}</span>
|
||||
)}
|
||||
</div>
|
||||
{primaryEmail && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.importButton}
|
||||
title="In CRM-Kontakten suchen"
|
||||
onClick={() => navigate(`/crm/contacts?search=${encodeURIComponent(primaryEmail)}`)}
|
||||
>
|
||||
CRM
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Aufgaben ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const importanceColors = { high: '#ef4444', normal: 'var(--color-text-muted)', low: '#9ca3af' };
|
||||
const importanceLabel = { high: '!', normal: '', low: '' };
|
||||
|
||||
function TasksList() {
|
||||
const { data, isLoading, error } = useOffice365Tasks();
|
||||
const taskLists: M365TaskList[] = data?.data ?? [];
|
||||
|
||||
if (isLoading) return <p className={styles.loadingText}>Aufgaben werden geladen…</p>;
|
||||
if (error) return <p className={styles.errorText}>Aufgaben konnten nicht geladen werden.</p>;
|
||||
|
||||
const allTasks = taskLists.flatMap((l) =>
|
||||
l.tasks.map((t) => ({ ...t, listName: l.displayName })),
|
||||
);
|
||||
|
||||
if (!allTasks.length) return <p className={styles.emptyText}>Keine offenen Aufgaben.</p>;
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{allTasks.map((task) => (
|
||||
<div key={task.id} className={styles.taskCard}>
|
||||
<div
|
||||
className={styles.taskImportance}
|
||||
style={{ color: importanceColors[task.importance as keyof typeof importanceColors] ?? 'var(--color-text-muted)' }}
|
||||
>
|
||||
{importanceLabel[task.importance as keyof typeof importanceLabel] ?? ''}
|
||||
</div>
|
||||
<div className={styles.taskContent}>
|
||||
<span className={styles.taskTitle}>{task.title}</span>
|
||||
<span className={styles.taskMeta}>
|
||||
{task.listName}
|
||||
{task.dueDateTime && (
|
||||
<> · Fällig {formatDateOnly(task.dueDateTime.dateTime)}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`${styles.taskStatus} ${styles[`status_${task.status}`] ?? ''}`}>
|
||||
{task.status === 'notStarted' ? 'Offen' : task.status === 'inProgress' ? 'In Arbeit' : task.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Office365Page() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('emails');
|
||||
const { data: integrationsData, isLoading: integrationsLoading } = useIntegrations();
|
||||
const isConnected = integrationsData?.data?.some(
|
||||
(i) => i.provider === 'MICROSOFT_365' && i.connected,
|
||||
) ?? false;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.pageHeader}>
|
||||
<h1 className={styles.pageTitle}>Microsoft Office 365</h1>
|
||||
{!integrationsLoading && (
|
||||
<span className={isConnected ? styles.connectedBadge : styles.disconnectedBadge}>
|
||||
{isConnected ? '● Verbunden' : '○ Nicht verbunden'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!integrationsLoading && !isConnected && (
|
||||
<div className={styles.connectPrompt}>
|
||||
<p>Verbinden Sie Ihr Microsoft 365-Konto, um E-Mails, Kalender, Kontakte und Aufgaben zu sehen.</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.connectButton}
|
||||
onClick={() => integrationsApi.connectM365()}
|
||||
>
|
||||
Microsoft 365 verbinden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && (
|
||||
<>
|
||||
{/* Tab-Navigation */}
|
||||
<div className={styles.tabBar}>
|
||||
{TAB_LABELS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={`${styles.tab} ${activeTab === tab.id ? styles.activeTab : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab-Inhalt */}
|
||||
<div className={styles.tabContent}>
|
||||
{activeTab === 'emails' && <EmailsList />}
|
||||
{activeTab === 'calendar' && <CalendarList />}
|
||||
{activeTab === 'contacts' && <ContactsList />}
|
||||
{activeTab === 'tasks' && <TasksList />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -970,6 +970,7 @@ export interface M365Email {
|
|||
receivedDateTime: string;
|
||||
from: { emailAddress: M365EmailAddress } | null;
|
||||
toRecipients: Array<{ emailAddress: M365EmailAddress }>;
|
||||
hasAttachments: boolean;
|
||||
isRead: boolean;
|
||||
webLink: string;
|
||||
}
|
||||
|
|
@ -981,6 +982,7 @@ export interface M365CalendarEvent {
|
|||
end: { dateTime: string; timeZone: string };
|
||||
location?: { displayName?: string };
|
||||
organizer?: { emailAddress: M365EmailAddress };
|
||||
attendees?: Array<{ emailAddress: { name: string; address: string }; type: string }>;
|
||||
isOnlineMeeting: boolean;
|
||||
onlineMeetingUrl?: string;
|
||||
webLink: string;
|
||||
|
|
@ -1001,3 +1003,13 @@ export interface M365TaskList {
|
|||
displayName: string;
|
||||
tasks: M365Task[];
|
||||
}
|
||||
|
||||
export interface M365Contact {
|
||||
id: string;
|
||||
displayName: string;
|
||||
emailAddresses: Array<{ name?: string; address?: string }>;
|
||||
mobilePhone: string | null;
|
||||
businessPhones: string[];
|
||||
jobTitle: string | null;
|
||||
companyName: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { CrmSettingsPage } from '../crm/settings/CrmSettingsPage';
|
|||
import { LexwareSyncPage } from '../crm/lexware/LexwareSyncPage';
|
||||
import { ForecastPage } from '../crm/forecast/ForecastPage';
|
||||
import { KanbanPage } from '../crm/deals/KanbanPage';
|
||||
import { Office365Page } from '../crm/office365/Office365Page';
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
|
@ -74,6 +75,7 @@ export function App() {
|
|||
<Route path="crm/pipelines" element={<CrmModuleGuard module="pipelines"><PipelinesPage /></CrmModuleGuard>} />
|
||||
<Route path="crm/forecast" element={<CrmModuleGuard module="deals"><ForecastPage /></CrmModuleGuard>} />
|
||||
<Route path="crm/kanban" element={<CrmModuleGuard module="deals"><KanbanPage /></CrmModuleGuard>} />
|
||||
<Route path="crm/office365" element={<Office365Page />} />
|
||||
<Route path="crm/import" element={<Navigate to="/crm/settings" replace />} />
|
||||
<Route path="crm/settings" element={<CrmSettingsPage />} />
|
||||
<Route path="crm/lexware-sync" element={<LexwareSyncPage />} />
|
||||
|
|
|
|||
|
|
@ -416,6 +416,30 @@ export function AppLayout() {
|
|||
{!collapsed && 'Kanban'}
|
||||
</NavLink>
|
||||
)}
|
||||
<NavLink
|
||||
to="/crm/office365"
|
||||
className={({ isActive }) =>
|
||||
`${styles.navLink} ${isActive ? styles.active : ''}`
|
||||
}
|
||||
title="Office 365"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="1" y="1" width="6.5" height="6.5" rx="1" />
|
||||
<rect x="8.5" y="1" width="6.5" height="6.5" rx="1" />
|
||||
<rect x="1" y="8.5" width="6.5" height="6.5" rx="1" />
|
||||
<rect x="8.5" y="8.5" width="6.5" height="6.5" rx="1" />
|
||||
</svg>
|
||||
{!collapsed && 'Office 365'}
|
||||
</NavLink>
|
||||
{/* CRM Einstellungen (nur Admins) */}
|
||||
{isAdmin && (
|
||||
<NavLink
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue