mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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:
parent
b197660ac8
commit
6c51eb5e83
7 changed files with 345 additions and 50 deletions
25
Summarize.md
25
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// --------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@ import { RedisModule } from '../redis/redis.module';
|
|||
imports: [CrmPrismaModule, RedisModule],
|
||||
controllers: [GraphController, Office365Controller],
|
||||
providers: [GraphService],
|
||||
exports: [GraphService],
|
||||
})
|
||||
export class GraphModule {}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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,16 +704,100 @@ 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
|
||||
{/* 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>
|
||||
<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' };
|
||||
<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>
|
||||
|
||||
{/* 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}
|
||||
|
|
@ -707,11 +821,11 @@ export function ContactDetailPage() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{m365Tab === 'emails' && <EmailsTab contactId={contact.id} />}
|
||||
{m365Tab === 'calendar' && <CalendarTab contactId={contact.id} />}
|
||||
{m365Tab === 'tasks' && <TasksTab contactId={contact.id} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue