From 6c51eb5e83fac38cf8f4f7470288a7a5b3a3e693 Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Fri, 13 Mar 2026 19:57:07 +0100 Subject: [PATCH] =?UTF-8?q?feat(crm):=20Kontakt-Detailseite=20=E2=80=93=20?= =?UTF-8?q?Breite,=20Outlook=20Daten,=20Outlook-Push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - max-width 960px auf Kontakt-Detailseite - M365-Sektion umbenannt zu "Outlook Daten", default eingeklappt - Aufgaben-Tab entfernt (nur noch E-Mails + Kalender) - "In Outlook speichern"-Button: pusht/aktualisiert Kontakt in Outlook-Kontakte via MS Graph POST/PATCH /me/contacts - Kontaktdaten: Typ, Status immer sichtbar, Bundesland (state) in Adresse - Backend: GraphService exportiert, pushContactToOutlook-Methode, POST /crm/contacts/:id/push-to-outlook Co-Authored-By: Claude Sonnet 4.6 --- Summarize.md | 25 +++ .../src/contacts/contacts.controller.ts | 39 ++++ .../src/contacts/contacts.module.ts | 3 +- .../crm-service/src/graph/graph.module.ts | 1 + .../crm-service/src/graph/graph.service.ts | 108 +++++++++ packages/frontend/src/crm/api.ts | 7 + .../src/crm/contacts/ContactDetailPage.tsx | 212 ++++++++++++++---- 7 files changed, 345 insertions(+), 50 deletions(-) diff --git a/Summarize.md b/Summarize.md index 0767f26..b9fce5a 100644 --- a/Summarize.md +++ b/Summarize.md @@ -6,6 +6,31 @@ --- +### Aenderungen 2026-03-13 (11): Kontakt-Detailseite – Breite, Outlook-Daten-Sektion, Felder, Outlook-Push + +#### Backend (crm-service) +- `graph/graph.module.ts` — `exports: [GraphService]` ergaenzt (GraphService ist jetzt in anderen Modulen nutzbar) +- `graph/graph.service.ts` — Neue Methode `pushContactToOutlook(userJwt, contact)`: prueft via `GET /me/contacts?$filter=emailAddresses/any(...)` ob Kontakt in Outlook existiert; existiert er → PATCH (Update); existiert er nicht → POST (Neuanlage); befuellt Outlook-Kontakt mit givenName, surname, jobTitle, department, companyName, emailAddresses, businessPhones, mobilePhone, businessAddress +- `contacts/contacts.module.ts` — `GraphModule` importiert (ermoeglicht GraphService-Injektion) +- `contacts/contacts.controller.ts` — Neuer Endpoint `POST /crm/contacts/:id/push-to-outlook`: laedt CRM-Kontakt, leitet alle relevanten Felder an `graphService.pushContactToOutlook` weiter; gibt `{ created: boolean, outlookContactId: string }` zurueck + +#### Frontend +- `crm/api.ts` — `contactsApi.pushToOutlook(id)`: `POST /crm/contacts/:id/push-to-outlook` +- `crm/contacts/ContactDetailPage.tsx`: + - **Breite**: Aeusserer Wrapper mit `maxWidth: 960px, margin: 0 auto` + - **Kontaktdaten**: Neues Feld "Typ" (Person/Organisation) ganz oben in der rechten Spalte; "Status" immer angezeigt (bisher nur wenn != ACTIVE); `contact.companyName` als Fallback wenn kein verknuepftes Unternehmen; `state` (Bundesland) in der Adresszeile ergaenzt; `state`-Bedingung fuer Adressblock + - **Outlook Daten** (neue Bezeichnung fuer "Microsoft 365"): Sektion einklappbar (default: eingeklappt), Chevron-Toggle im Karten-Header; "Aufgaben"-Tab entfernt (M365Tab jetzt nur `emails | calendar`); `TasksTab`-Import entfernt; "In Outlook speichern"-Button im Header mit Status-Feedback (Speichern…/✓ In Outlook gespeichert/Fehler) + - `CONTACT_TYPE_LABELS` Konstante, `handlePushToOutlook` Funktion, `outlookExpanded` + `pushStatus` States + +#### TypeScript +- `npx tsc --noEmit` in packages/frontend: 0 Fehler +- `npx tsc --noEmit` in packages/crm-service: 0 neue Fehler (vorhandene pre-existing Fehler aus Prisma-Client-Mismatch auf lokalem Mac — werden durch `prisma generate` auf Server behoben) + +#### Deployment-Hinweis (Schritt 11) +- Rebuild + Restart: crm-service + frontend (kein neues DB-Schema, kein migrate benoetigt) + +--- + ### Aenderungen 2026-03-13 (10): Dediziertes Projektanfrage-Formular + Button in Vorgänge-Liste #### Frontend diff --git a/packages/crm-service/src/contacts/contacts.controller.ts b/packages/crm-service/src/contacts/contacts.controller.ts index b882634..10d62eb 100644 --- a/packages/crm-service/src/contacts/contacts.controller.ts +++ b/packages/crm-service/src/contacts/contacts.controller.ts @@ -7,12 +7,14 @@ import { Body, Param, Query, + Req, ParseUUIDPipe, HttpCode, HttpStatus, UseGuards, NotFoundException, } from '@nestjs/common'; +import { Request } from 'express'; import { ApiTags, ApiOperation, @@ -25,6 +27,7 @@ import { UpdateContactDto } from './dto/update-contact.dto'; import { QueryContactsDto } from './dto/query-contacts.dto'; import { AddOwnerDto } from '../common/dto/owner.dto'; import { OwnersService } from '../owners/owners.service'; +import { GraphService } from '../graph/graph.service'; import { CurrentUser, JwtPayload } from '../common/decorators'; import { TenantGuard } from '../auth/guards/tenant.guard'; import { @@ -40,6 +43,7 @@ export class ContactsController { constructor( private readonly contactsService: ContactsService, private readonly ownersService: OwnersService, + private readonly graphService: GraphService, ) {} @Post() @@ -122,6 +126,41 @@ export class ContactsController { return singleResponse(contact); } + // -------------------------------------------------------- + // Outlook Sync + // -------------------------------------------------------- + + @Post(':id/push-to-outlook') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'CRM-Kontakt in Outlook-Kontakte pushen / synchronisieren' }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async pushToOutlook( + @CurrentUser() user: JwtPayload, + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + ) { + const contact = await this.contactsService.findOne(user.tenantId!, id); + const jwt = (req.headers.authorization ?? '').replace('Bearer ', ''); + const result = await this.graphService.pushContactToOutlook(jwt, { + firstName: contact.firstName, + lastName: contact.lastName, + companyName: contact.companyName, + email: contact.email, + phone: contact.phone, + mobile: contact.mobile, + position: contact.position, + department: contact.department, + street: contact.street, + zip: contact.zip, + city: contact.city, + state: contact.state, + country: contact.country, + website: contact.website, + notes: contact.notes, + }); + return { success: true, data: result }; + } + // -------------------------------------------------------- // Owner Endpoints // -------------------------------------------------------- diff --git a/packages/crm-service/src/contacts/contacts.module.ts b/packages/crm-service/src/contacts/contacts.module.ts index 81bc8da..f79594c 100644 --- a/packages/crm-service/src/contacts/contacts.module.ts +++ b/packages/crm-service/src/contacts/contacts.module.ts @@ -4,9 +4,10 @@ import { ContactsService } from './contacts.service'; import { LexwareModule } from '../lexware/lexware.module'; import { OwnersModule } from '../owners/owners.module'; import { CustomFieldsModule } from '../custom-fields/custom-fields.module'; +import { GraphModule } from '../graph/graph.module'; @Module({ - imports: [LexwareModule, OwnersModule, CustomFieldsModule], + imports: [LexwareModule, OwnersModule, CustomFieldsModule, GraphModule], controllers: [ContactsController], providers: [ContactsService], exports: [ContactsService], diff --git a/packages/crm-service/src/graph/graph.module.ts b/packages/crm-service/src/graph/graph.module.ts index 6f0c47d..3ef639f 100644 --- a/packages/crm-service/src/graph/graph.module.ts +++ b/packages/crm-service/src/graph/graph.module.ts @@ -9,5 +9,6 @@ import { RedisModule } from '../redis/redis.module'; imports: [CrmPrismaModule, RedisModule], controllers: [GraphController, Office365Controller], providers: [GraphService], + exports: [GraphService], }) export class GraphModule {} diff --git a/packages/crm-service/src/graph/graph.service.ts b/packages/crm-service/src/graph/graph.service.ts index 96df40b..ac8f1e4 100644 --- a/packages/crm-service/src/graph/graph.service.ts +++ b/packages/crm-service/src/graph/graph.service.ts @@ -650,6 +650,114 @@ export class GraphService { }; } + /** + * CRM-Kontakt in Outlook-Kontakte pushen / synchronisieren. + * Sucht anhand der E-Mail-Adresse nach einem vorhandenen Outlook-Kontakt. + * Wenn vorhanden → PATCH (Update), sonst → POST (Neu anlegen). + */ + async pushContactToOutlook( + userJwt: string, + contact: { + firstName: string | null; + lastName: string | null; + companyName: string | null; + email: string | null; + phone: string | null; + mobile: string | null; + position: string | null; + department: string | null; + street: string | null; + zip: string | null; + city: string | null; + state: string | null; + country: string | null; + website: string | null; + notes: string | null; + }, + ): Promise<{ created: boolean; outlookContactId: string }> { + const accessToken = await this.getM365Token(userJwt); + + const displayName = [contact.firstName, contact.lastName] + .filter(Boolean) + .join(' ') || contact.companyName || ''; + + const outlookPayload: Record = { + givenName: contact.firstName ?? '', + surname: contact.lastName ?? '', + jobTitle: contact.position ?? '', + department: contact.department ?? '', + companyName: contact.companyName ?? '', + businessHomePage: contact.website ?? '', + personalNotes: contact.notes ?? '', + }; + + if (contact.email) { + outlookPayload['emailAddresses'] = [ + { address: contact.email, name: displayName }, + ]; + } + + const businessPhones: string[] = []; + if (contact.phone) businessPhones.push(contact.phone); + if (businessPhones.length > 0) { + outlookPayload['businessPhones'] = businessPhones; + } + if (contact.mobile) { + outlookPayload['mobilePhone'] = contact.mobile; + } + + if (contact.street || contact.zip || contact.city) { + outlookPayload['businessAddress'] = { + street: contact.street ?? '', + city: contact.city ?? '', + state: contact.state ?? '', + postalCode: contact.zip ?? '', + countryOrRegion: contact.country ?? '', + }; + } + + // Vorhandenen Outlook-Kontakt anhand der E-Mail suchen + let existingId: string | null = null; + if (contact.email) { + try { + const search = await this.graphGet<{ value: Array<{ id: string }> }>( + accessToken, + '/me/contacts', + { + $filter: `emailAddresses/any(a:a/address eq '${contact.email}')`, + $top: '1', + $select: 'id', + }, + ); + existingId = search.value?.[0]?.id ?? null; + } catch { + // Suche schlägt fehl → Neuanlage + } + } + + if (existingId) { + await this.graphPatch( + accessToken, + `/me/contacts/${existingId}`, + outlookPayload, + ); + this.logger.debug( + `Graph: CRM-Kontakt in Outlook aktualisiert (${existingId})`, + ); + return { created: false, outlookContactId: existingId }; + } + + const created = await this.graphPost<{ id: string }>( + accessToken, + '/me/contacts', + outlookPayload, + ); + this.logger.debug( + `Graph: CRM-Kontakt in Outlook erstellt (${created.id})`, + ); + return { created: true, outlookContactId: created.id }; + } + /** * Microsoft-365-Profilbild laden (96x96 JPEG). * Gibt Base64 Data-URL zurück, oder null wenn kein Foto vorhanden (404). diff --git a/packages/frontend/src/crm/api.ts b/packages/frontend/src/crm/api.ts index f85d50d..5a6abdf 100644 --- a/packages/frontend/src/crm/api.ts +++ b/packages/frontend/src/crm/api.ts @@ -116,6 +116,13 @@ export const contactsApi = { api .get>('/crm/contacts/lookup', { params: { email } }) .then((r) => r.data), + + pushToOutlook: (id: string) => + api + .post<{ success: boolean; data: { created: boolean; outlookContactId: string } }>( + `/crm/contacts/${id}/push-to-outlook`, + ) + .then((r) => r.data), }; // --- Deals --- diff --git a/packages/frontend/src/crm/contacts/ContactDetailPage.tsx b/packages/frontend/src/crm/contacts/ContactDetailPage.tsx index de6bb56..19fb128 100644 --- a/packages/frontend/src/crm/contacts/ContactDetailPage.tsx +++ b/packages/frontend/src/crm/contacts/ContactDetailPage.tsx @@ -1,18 +1,23 @@ import { useState } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import { useContact, useDeals, useDeleteContact } from '../hooks'; +import { contactsApi } from '../api'; import { ContactFormModal } from './ContactFormModal'; import { ActivityFormModal } from '../activities/ActivityFormModal'; import { Modal } from '../../components/Modal'; import { CustomFieldsDisplay } from '../CustomFieldsDisplay'; import { EmailsTab } from './EmailsTab'; import { CalendarTab } from './CalendarTab'; -import { TasksTab } from './TasksTab'; import type { Contact, Activity, ActivityType } from '../types'; import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types'; import styles from './ContactDetailPage.module.css'; -type M365Tab = 'emails' | 'calendar' | 'tasks'; +type M365Tab = 'emails' | 'calendar'; + +const CONTACT_TYPE_LABELS: Record = { + PERSON: 'Person', + ORGANIZATION: 'Organisation', +}; const ACTIVITY_TYPE_LABELS: Record = { NOTE: 'Notiz', @@ -118,6 +123,8 @@ export function ContactDetailPage() { const [isActivityOpen, setActivityOpen] = useState(false); const [isDeleteOpen, setDeleteOpen] = useState(false); const [m365Tab, setM365Tab] = useState('emails'); + const [outlookExpanded, setOutlookExpanded] = useState(false); + const [pushStatus, setPushStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); // Aktivitäten-Filter const [actTypeFilter, setActTypeFilter] = useState('ALL'); @@ -158,8 +165,20 @@ export function ContactDetailPage() { setActDateTo(''); } + async function handlePushToOutlook() { + setPushStatus('loading'); + try { + await contactsApi.pushToOutlook(contact.id); + setPushStatus('success'); + setTimeout(() => setPushStatus('idle'), 3000); + } catch { + setPushStatus('error'); + setTimeout(() => setPushStatus('idle'), 3000); + } + } + return ( -
+
{/* Back link */} + Typ + + {CONTACT_TYPE_LABELS[contact.type] ?? contact.type} + + Unternehmen {contact.company ? ( @@ -318,6 +342,8 @@ export function ContactDetailPage() { )} + ) : contact.companyName ? ( + {contact.companyName} ) : } @@ -331,6 +357,20 @@ export function ContactDetailPage() { {contact.department ?? } + Status + + {ENTITY_STATUS_LABELS[contact.status] ?? contact.status} + + {contact.birthday && ( <> Geburtsdatum @@ -347,29 +387,19 @@ export function ContactDetailPage() { )} - {contact.status && contact.status !== 'ACTIVE' && ( - <> - Status - - {ENTITY_STATUS_LABELS[contact.status] ?? contact.status} - - - )}
{/* Address — full-width below sub-columns */} - {(contact.street || contact.zip || contact.city) && ( + {(contact.street || contact.zip || contact.city || contact.state) && (
Adresse {contact.street && <>{contact.street}
} {contact.zip} {contact.city} - {contact.country && contact.country !== 'DE' && <>, {contact.country}} + {contact.state && <>, {contact.state}} + {contact.country && contact.country !== 'DE' && <>
{contact.country}}
@@ -674,44 +704,128 @@ export function ContactDetailPage() { )} - {/* ── Microsoft 365 ── */} + {/* ── Outlook Daten ── */} {contact.email && (
-
-

- Microsoft 365 -

-
- {(['emails', 'calendar', 'tasks'] as M365Tab[]).map((tab) => { - const labels: Record = { emails: 'E-Mails', calendar: 'Kalender', tasks: 'Aufgaben' }; - return ( - - ); - })} + {/* Karten-Header: Titel + Outlook-Push-Button + Toggle */} +
+ + +
+ {pushStatus === 'success' && ( + + ✓ In Outlook gespeichert + + )} + {pushStatus === 'error' && ( + + Fehler beim Speichern + + )} +
- {m365Tab === 'emails' && } - {m365Tab === 'calendar' && } - {m365Tab === 'tasks' && } + {/* Tabs + Inhalt (nur wenn ausgeklappt) */} + {outlookExpanded && ( +
+
+ {(['emails', 'calendar'] as M365Tab[]).map((tab) => { + const labels: Record = { emails: 'E-Mails', calendar: 'Kalender' }; + return ( + + ); + })} +
+ + {m365Tab === 'emails' && } + {m365Tab === 'calendar' && } +
+ )}
)}