feat: Profil mit Microsoft 365 Daten anreichern

- GraphService: getM365Profile() lädt givenName, surname, mobilePhone,
  businessPhones, city, streetAddress, postalCode, jobTitle via /me
- Office365Controller: GET /crm/office365/profile Endpunkt
- Frontend types: M365UserProfile Interface
- Frontend api: office365Api.getM365Profile()
- ProfilePage: O365 uebernehmen Button (nur wenn M365 verbunden)
  fuellt leere Felder: Vorname, Nachname, Telefon, Mobil, Ort, Strasse, PLZ
  Bestehende Werte werden NICHT ueberschrieben; Feedback zeigt welche
  Felder uebernommen wurden; User muss Speichern klicken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-13 12:38:43 +01:00
parent 403c581e57
commit 347a6ca418
6 changed files with 223 additions and 1 deletions

View file

@ -70,6 +70,18 @@ export interface M365Contact {
companyName: string | null; companyName: string | null;
} }
export interface M365UserProfile {
givenName: string | null;
surname: string | null;
displayName: string | null;
mobilePhone: string | null;
businessPhones: string[];
city: string | null;
streetAddress: string | null;
postalCode: string | null;
jobTitle: string | null;
}
export interface M365MailFolder { export interface M365MailFolder {
id: string; id: string;
displayName: string; displayName: string;
@ -597,6 +609,38 @@ export class GraphService {
this.logger.debug(`Graph: Aufgabe ${taskId} als erledigt markiert`); this.logger.debug(`Graph: Aufgabe ${taskId} als erledigt markiert`);
} }
/** Microsoft-365-Benutzerprofil laden (für Profilanreicherung) */
async getM365Profile(userJwt: string): Promise<M365UserProfile> {
const accessToken = await this.getM365Token(userJwt);
const data = await this.graphGet<{
givenName?: string | null;
surname?: string | null;
displayName?: string | null;
mobilePhone?: string | null;
businessPhones?: string[];
city?: string | null;
streetAddress?: string | null;
postalCode?: string | null;
jobTitle?: string | null;
}>(accessToken, '/me', {
$select:
'givenName,surname,displayName,mobilePhone,businessPhones,city,streetAddress,postalCode,jobTitle',
});
return {
givenName: data.givenName ?? null,
surname: data.surname ?? null,
displayName: data.displayName ?? null,
mobilePhone: data.mobilePhone ?? null,
businessPhones: data.businessPhones ?? [],
city: data.city ?? null,
streetAddress: data.streetAddress ?? null,
postalCode: data.postalCode ?? null,
jobTitle: data.jobTitle ?? null,
};
}
/** E-Mails in einem bestimmten Ordner (mit optionalem Tages-Filter) */ /** E-Mails in einem bestimmten Ordner (mit optionalem Tages-Filter) */
async getMailsByFolder( async getMailsByFolder(
userJwt: string, userJwt: string,

View file

@ -112,6 +112,14 @@ export class Office365Controller {
return { success: true }; return { success: true };
} }
/** Microsoft-365-Benutzerprofil abrufen (für Profilanreicherung) */
@Get('profile')
async getProfile(@Req() req: Request & { user: JwtUser }) {
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
const profile = await this.graphService.getM365Profile(jwt);
return { success: true, data: profile };
}
@Get('folders') @Get('folders')
async getMailFolders(@Req() req: Request & { user: JwtUser }) { async getMailFolders(@Req() req: Request & { user: JwtUser }) {
const jwt = (req.headers.authorization ?? '').replace('Bearer ', ''); const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');

View file

@ -78,6 +78,7 @@ import type {
CrmOpenTask, CrmOpenTask,
M365Contact, M365Contact,
M365MailFolder, M365MailFolder,
M365UserProfile,
} from './types'; } from './types';
// --- Contacts --- // --- Contacts ---
@ -900,4 +901,9 @@ export const office365Api = {
{}, {},
) )
.then((r) => r.data), .then((r) => r.data),
getM365Profile: () =>
api
.get<{ success: boolean; data: M365UserProfile }>('/crm/office365/profile')
.then((r) => r.data),
}; };

View file

@ -1054,6 +1054,19 @@ export interface M365MailFolder {
wellKnownName?: string | null; // optional — nicht von allen Exchange-Tenants unterstützt wellKnownName?: string | null; // optional — nicht von allen Exchange-Tenants unterstützt
} }
/** Microsoft-365-Benutzerprofil (für Profilanreicherung) */
export interface M365UserProfile {
givenName: string | null;
surname: string | null;
displayName: string | null;
mobilePhone: string | null;
businessPhones: string[];
city: string | null;
streetAddress: string | null;
postalCode: string | null;
jobTitle: string | null;
}
/** Minimaler CRM-Kontakt für E-Mail-Lookup */ /** Minimaler CRM-Kontakt für E-Mail-Lookup */
export interface CrmContactLookup { export interface CrmContactLookup {
id: string; id: string;

View file

@ -398,3 +398,66 @@
white-space: nowrap; white-space: nowrap;
} }
} }
/* ── O365-Profilanreicherung Banner ── */
.enrichBanner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.625rem;
background: rgba(59, 130, 246, 0.05);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: var(--radius-md);
padding: 0.625rem 0.875rem;
margin-bottom: 1rem;
}
.enrichInfo {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.enrichIcon {
color: var(--color-primary);
flex-shrink: 0;
display: flex;
}
.enrichLabel {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.enrichBtn {
padding: 0.3125rem 0.875rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.15s;
flex-shrink: 0;
}
.enrichBtn:hover:not(:disabled) {
opacity: 0.88;
}
.enrichBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.enrichFeedback {
width: 100%;
margin: 0;
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
}

View file

@ -6,7 +6,7 @@ import { UserAvatar } from '../components/UserAvatar';
import { resizeImageToBase64 } from '../utils/imageUtils'; import { resizeImageToBase64 } from '../utils/imageUtils';
import { ExpertProfileTab } from './ExpertProfileTab'; import { ExpertProfileTab } from './ExpertProfileTab';
import { useIntegrations, useDisconnectM365 } from '../crm/hooks'; import { useIntegrations, useDisconnectM365 } from '../crm/hooks';
import { integrationsApi } from '../crm/api'; import { integrationsApi, office365Api } from '../crm/api';
import styles from './ProfilePage.module.css'; import styles from './ProfilePage.module.css';
type ProfileTab = 'personal' | 'expert' | 'password' | 'integrations'; type ProfileTab = 'personal' | 'expert' | 'password' | 'integrations';
@ -33,6 +33,11 @@ export function ProfilePage() {
const [avatarLoading, setAvatarLoading] = useState(false); const [avatarLoading, setAvatarLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// --- O365-Profilanreicherung ---
const [enrichLoading, setEnrichLoading] = useState(false);
const [enrichMsg, setEnrichMsg] = useState('');
const [enrichError, setEnrichError] = useState('');
// --- Passwort ändern --- // --- Passwort ändern ---
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
@ -169,6 +174,61 @@ export function ProfilePage() {
} }
}; };
// === Handler: Profil aus O365 anreichern ===
const handleEnrichFromO365 = async () => {
setEnrichMsg('');
setEnrichError('');
setEnrichLoading(true);
try {
const result = await office365Api.getM365Profile();
const p = result.data;
const filled: string[] = [];
if (!firstName && p.givenName) {
setFirstName(p.givenName);
filled.push('Vorname');
}
if (!lastName && p.surname) {
setLastName(p.surname);
filled.push('Nachname');
}
if (!phone && p.businessPhones?.[0]) {
setPhone(p.businessPhones[0]);
filled.push('Telefon');
}
if (!mobile && p.mobilePhone) {
setMobile(p.mobilePhone);
filled.push('Mobil');
}
if (!city && p.city) {
setCity(p.city);
filled.push('Ort');
}
if (!street && p.streetAddress) {
setStreet(p.streetAddress);
filled.push('Straße');
}
if (!postalCode && p.postalCode) {
setPostalCode(p.postalCode);
filled.push('PLZ');
}
if (filled.length > 0) {
setEnrichMsg(
`${filled.length} Felder übernommen: ${filled.join(', ')}. Bitte prüfen und speichern.`,
);
} else {
setEnrichMsg('Alle Felder sind bereits ausgefüllt — keine Änderungen.');
}
} catch {
setEnrichError('O365-Profil konnte nicht geladen werden.');
} finally {
setEnrichLoading(false);
}
};
// === Handler: Passwort ändern === // === Handler: Passwort ändern ===
const handlePasswordChange = async (e: FormEvent) => { const handlePasswordChange = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -432,6 +492,34 @@ export function ProfilePage() {
{/* --- Rechte Spalte: Formular --- */} {/* --- Rechte Spalte: Formular --- */}
<div className={styles.formColumn}> <div className={styles.formColumn}>
{/* O365-Anreicherung (nur wenn verbunden) */}
{m365Integration?.connected && (
<div className={styles.enrichBanner}>
<div className={styles.enrichInfo}>
<span className={styles.enrichIcon}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/>
<path d="M15 9l-6 6M9 9l6 6"/>
</svg>
</span>
<span className={styles.enrichLabel}>
Fehlende Felder aus Microsoft 365 übernehmen
</span>
</div>
<button
type="button"
className={styles.enrichBtn}
onClick={handleEnrichFromO365}
disabled={enrichLoading}
>
{enrichLoading ? 'Lädt…' : '↓ O365 übernehmen'}
</button>
{enrichMsg && <div className={`${styles.success} ${styles.enrichFeedback}`}>{enrichMsg}</div>}
{enrichError && <div className={`${styles.error} ${styles.enrichFeedback}`}>{enrichError}</div>}
</div>
)}
<form onSubmit={handleProfileUpdate} className={styles.form}> <form onSubmit={handleProfileUpdate} className={styles.form}>
{profileMsg && <div className={styles.success}>{profileMsg}</div>} {profileMsg && <div className={styles.success}>{profileMsg}</div>}
{profileError && <div className={styles.error}>{profileError}</div>} {profileError && <div className={styles.error}>{profileError}</div>}