feat(crm): Kontakt-Detailseite – Breite, Outlook Daten, Outlook-Push

- 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 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-13 19:57:07 +01:00
parent b197660ac8
commit 6c51eb5e83
7 changed files with 345 additions and 50 deletions

View file

@ -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

View file

@ -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
// --------------------------------------------------------

View file

@ -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],

View file

@ -9,5 +9,6 @@ import { RedisModule } from '../redis/redis.module';
imports: [CrmPrismaModule, RedisModule],
controllers: [GraphController, Office365Controller],
providers: [GraphService],
exports: [GraphService],
})
export class GraphModule {}

View file

@ -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<string, unknown> = {
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<unknown>(
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).

View file

@ -116,6 +116,13 @@ export const contactsApi = {
api
.get<SingleResponse<CrmContactLookup>>('/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 ---

View file

@ -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<string, string> = {
PERSON: 'Person',
ORGANIZATION: 'Organisation',
};
const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
NOTE: 'Notiz',
@ -118,6 +123,8 @@ export function ContactDetailPage() {
const [isActivityOpen, setActivityOpen] = useState(false);
const [isDeleteOpen, setDeleteOpen] = useState(false);
const [m365Tab, setM365Tab] = useState<M365Tab>('emails');
const [outlookExpanded, setOutlookExpanded] = useState(false);
const [pushStatus, setPushStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
// Aktivitäten-Filter
const [actTypeFilter, setActTypeFilter] = useState<ActivityType | 'ALL'>('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 (
<div>
<div style={{ maxWidth: '960px', margin: '0 auto' }}>
{/* Back link */}
<Link to="/crm/contacts" className={styles.backLink}>
<svg
@ -302,6 +321,11 @@ export function ContactDetailPage() {
{/* Right: Kontext */}
<div className={styles.infoGrid}>
<span className={styles.infoLabel}>Typ</span>
<span className={styles.infoValue}>
{CONTACT_TYPE_LABELS[contact.type] ?? contact.type}
</span>
<span className={styles.infoLabel}>Unternehmen</span>
<span className={styles.infoValue}>
{contact.company ? (
@ -318,6 +342,8 @@ export function ContactDetailPage() {
</span>
)}
</>
) : contact.companyName ? (
<span>{contact.companyName}</span>
) : <span className={styles.empty}></span>}
</span>
@ -331,6 +357,20 @@ export function ContactDetailPage() {
{contact.department ?? <span className={styles.empty}></span>}
</span>
<span className={styles.infoLabel}>Status</span>
<span
className={styles.infoValue}
style={{
color: contact.status === 'BLOCKED'
? '#991b1b'
: contact.status === 'INACTIVE'
? 'var(--color-text-muted)'
: 'var(--color-text)',
}}
>
{ENTITY_STATUS_LABELS[contact.status] ?? contact.status}
</span>
{contact.birthday && (
<>
<span className={styles.infoLabel}>Geburtsdatum</span>
@ -347,29 +387,19 @@ export function ContactDetailPage() {
</span>
</>
)}
{contact.status && contact.status !== 'ACTIVE' && (
<>
<span className={styles.infoLabel}>Status</span>
<span
className={styles.infoValue}
style={{ color: contact.status === 'BLOCKED' ? '#991b1b' : 'var(--color-text-muted)' }}
>
{ENTITY_STATUS_LABELS[contact.status] ?? contact.status}
</span>
</>
)}
</div>
</div>
{/* Address — full-width below sub-columns */}
{(contact.street || contact.zip || contact.city) && (
{(contact.street || contact.zip || contact.city || contact.state) && (
<div className={styles.addressRow}>
<div className={styles.infoGrid}>
<span className={styles.infoLabel}>Adresse</span>
<span className={styles.infoValue}>
{contact.street && <>{contact.street}<br /></>}
{contact.zip} {contact.city}
{contact.country && contact.country !== 'DE' && <>, {contact.country}</>}
{contact.state && <>, {contact.state}</>}
{contact.country && contact.country !== 'DE' && <><br />{contact.country}</>}
</span>
</div>
</div>
@ -674,44 +704,128 @@ export function ContactDetailPage() {
)}
</div>
{/* ── Microsoft 365 ── */}
{/* ── Outlook Daten ── */}
{contact.email && (
<div className={styles.card} style={{ marginTop: '1.5rem' }}>
<div style={{ marginBottom: '1rem' }}>
<h2 className={styles.cardTitle} style={{ margin: '0 0 0.75rem' }}>
Microsoft 365
</h2>
<div style={{ display: 'flex', gap: '0.5rem', borderBottom: '1px solid var(--color-border)', paddingBottom: '0' }}>
{(['emails', 'calendar', 'tasks'] as M365Tab[]).map((tab) => {
const labels: Record<M365Tab, string> = { emails: 'E-Mails', calendar: 'Kalender', tasks: 'Aufgaben' };
return (
<button
key={tab}
type="button"
onClick={() => setM365Tab(tab)}
style={{
padding: '0.375rem 0.75rem',
background: 'transparent',
border: 'none',
borderBottom: m365Tab === tab ? '2px solid var(--color-primary)' : '2px solid transparent',
fontSize: '0.875rem',
fontWeight: m365Tab === tab ? 600 : 400,
color: m365Tab === tab ? 'var(--color-primary)' : 'var(--color-text-secondary)',
cursor: 'pointer',
marginBottom: '-1px',
transition: 'all 0.15s',
}}
>
{labels[tab]}
</button>
);
})}
{/* Karten-Header: Titel + Outlook-Push-Button + Toggle */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '0.75rem',
}}
>
<button
type="button"
onClick={() => setOutlookExpanded((v) => !v)}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 0,
color: 'var(--color-text)',
}}
>
<h2 className={styles.cardTitle} style={{ margin: 0 }}>
Outlook Daten
</h2>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
style={{
color: 'var(--color-text-muted)',
transform: outlookExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s',
flexShrink: 0,
}}
>
<path d="M3 6l5 5 5-5" />
</svg>
</button>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexShrink: 0 }}>
{pushStatus === 'success' && (
<span style={{ fontSize: '0.8125rem', color: '#16a34a' }}>
In Outlook gespeichert
</span>
)}
{pushStatus === 'error' && (
<span style={{ fontSize: '0.8125rem', color: 'var(--color-error)' }}>
Fehler beim Speichern
</span>
)}
<button
type="button"
onClick={handlePushToOutlook}
disabled={pushStatus === 'loading'}
title="Kontakt in Outlook-Kontakte kopieren / aktualisieren"
style={{
padding: '0.25rem 0.625rem',
fontSize: '0.8125rem',
background: 'transparent',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: pushStatus === 'loading' ? 'wait' : 'pointer',
color: 'var(--color-text-secondary)',
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
opacity: pushStatus === 'loading' ? 0.6 : 1,
whiteSpace: 'nowrap',
}}
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 12l4-4-4-4M8 12h6" />
</svg>
{pushStatus === 'loading' ? 'Speichern…' : 'In Outlook speichern'}
</button>
</div>
</div>
{m365Tab === 'emails' && <EmailsTab contactId={contact.id} />}
{m365Tab === 'calendar' && <CalendarTab contactId={contact.id} />}
{m365Tab === 'tasks' && <TasksTab contactId={contact.id} />}
{/* Tabs + Inhalt (nur wenn ausgeklappt) */}
{outlookExpanded && (
<div style={{ marginTop: '1rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', borderBottom: '1px solid var(--color-border)', paddingBottom: '0', marginBottom: '0.75rem' }}>
{(['emails', 'calendar'] as M365Tab[]).map((tab) => {
const labels: Record<M365Tab, string> = { emails: 'E-Mails', calendar: 'Kalender' };
return (
<button
key={tab}
type="button"
onClick={() => setM365Tab(tab)}
style={{
padding: '0.375rem 0.75rem',
background: 'transparent',
border: 'none',
borderBottom: m365Tab === tab ? '2px solid var(--color-primary)' : '2px solid transparent',
fontSize: '0.875rem',
fontWeight: m365Tab === tab ? 600 : 400,
color: m365Tab === tab ? 'var(--color-primary)' : 'var(--color-text-secondary)',
cursor: 'pointer',
marginBottom: '-1px',
transition: 'all 0.15s',
}}
>
{labels[tab]}
</button>
);
})}
</div>
{m365Tab === 'emails' && <EmailsTab contactId={contact.id} />}
{m365Tab === 'calendar' && <CalendarTab contactId={contact.id} />}
</div>
)}
</div>
)}