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
|
### Aenderungen 2026-03-13 (10): Dediziertes Projektanfrage-Formular + Button in Vorgänge-Liste
|
||||||
|
|
||||||
#### Frontend
|
#### Frontend
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@ import {
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
|
Req,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
|
|
@ -25,6 +27,7 @@ import { UpdateContactDto } from './dto/update-contact.dto';
|
||||||
import { QueryContactsDto } from './dto/query-contacts.dto';
|
import { QueryContactsDto } from './dto/query-contacts.dto';
|
||||||
import { AddOwnerDto } from '../common/dto/owner.dto';
|
import { AddOwnerDto } from '../common/dto/owner.dto';
|
||||||
import { OwnersService } from '../owners/owners.service';
|
import { OwnersService } from '../owners/owners.service';
|
||||||
|
import { GraphService } from '../graph/graph.service';
|
||||||
import { CurrentUser, JwtPayload } from '../common/decorators';
|
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||||
import { TenantGuard } from '../auth/guards/tenant.guard';
|
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||||
import {
|
import {
|
||||||
|
|
@ -40,6 +43,7 @@ export class ContactsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly contactsService: ContactsService,
|
private readonly contactsService: ContactsService,
|
||||||
private readonly ownersService: OwnersService,
|
private readonly ownersService: OwnersService,
|
||||||
|
private readonly graphService: GraphService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
|
@ -122,6 +126,41 @@ export class ContactsController {
|
||||||
return singleResponse(contact);
|
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
|
// Owner Endpoints
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@ import { ContactsService } from './contacts.service';
|
||||||
import { LexwareModule } from '../lexware/lexware.module';
|
import { LexwareModule } from '../lexware/lexware.module';
|
||||||
import { OwnersModule } from '../owners/owners.module';
|
import { OwnersModule } from '../owners/owners.module';
|
||||||
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
|
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
|
||||||
|
import { GraphModule } from '../graph/graph.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [LexwareModule, OwnersModule, CustomFieldsModule],
|
imports: [LexwareModule, OwnersModule, CustomFieldsModule, GraphModule],
|
||||||
controllers: [ContactsController],
|
controllers: [ContactsController],
|
||||||
providers: [ContactsService],
|
providers: [ContactsService],
|
||||||
exports: [ContactsService],
|
exports: [ContactsService],
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,6 @@ import { RedisModule } from '../redis/redis.module';
|
||||||
imports: [CrmPrismaModule, RedisModule],
|
imports: [CrmPrismaModule, RedisModule],
|
||||||
controllers: [GraphController, Office365Controller],
|
controllers: [GraphController, Office365Controller],
|
||||||
providers: [GraphService],
|
providers: [GraphService],
|
||||||
|
exports: [GraphService],
|
||||||
})
|
})
|
||||||
export class GraphModule {}
|
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).
|
* Microsoft-365-Profilbild laden (96x96 JPEG).
|
||||||
* Gibt Base64 Data-URL zurück, oder null wenn kein Foto vorhanden (404).
|
* Gibt Base64 Data-URL zurück, oder null wenn kein Foto vorhanden (404).
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,13 @@ export const contactsApi = {
|
||||||
api
|
api
|
||||||
.get<SingleResponse<CrmContactLookup>>('/crm/contacts/lookup', { params: { email } })
|
.get<SingleResponse<CrmContactLookup>>('/crm/contacts/lookup', { params: { email } })
|
||||||
.then((r) => r.data),
|
.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 ---
|
// --- Deals ---
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,23 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import { useContact, useDeals, useDeleteContact } from '../hooks';
|
import { useContact, useDeals, useDeleteContact } from '../hooks';
|
||||||
|
import { contactsApi } from '../api';
|
||||||
import { ContactFormModal } from './ContactFormModal';
|
import { ContactFormModal } from './ContactFormModal';
|
||||||
import { ActivityFormModal } from '../activities/ActivityFormModal';
|
import { ActivityFormModal } from '../activities/ActivityFormModal';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { CustomFieldsDisplay } from '../CustomFieldsDisplay';
|
import { CustomFieldsDisplay } from '../CustomFieldsDisplay';
|
||||||
import { EmailsTab } from './EmailsTab';
|
import { EmailsTab } from './EmailsTab';
|
||||||
import { CalendarTab } from './CalendarTab';
|
import { CalendarTab } from './CalendarTab';
|
||||||
import { TasksTab } from './TasksTab';
|
|
||||||
import type { Contact, Activity, ActivityType } from '../types';
|
import type { Contact, Activity, ActivityType } from '../types';
|
||||||
import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types';
|
import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types';
|
||||||
import styles from './ContactDetailPage.module.css';
|
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> = {
|
const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
|
||||||
NOTE: 'Notiz',
|
NOTE: 'Notiz',
|
||||||
|
|
@ -118,6 +123,8 @@ export function ContactDetailPage() {
|
||||||
const [isActivityOpen, setActivityOpen] = useState(false);
|
const [isActivityOpen, setActivityOpen] = useState(false);
|
||||||
const [isDeleteOpen, setDeleteOpen] = useState(false);
|
const [isDeleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [m365Tab, setM365Tab] = useState<M365Tab>('emails');
|
const [m365Tab, setM365Tab] = useState<M365Tab>('emails');
|
||||||
|
const [outlookExpanded, setOutlookExpanded] = useState(false);
|
||||||
|
const [pushStatus, setPushStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||||
|
|
||||||
// Aktivitäten-Filter
|
// Aktivitäten-Filter
|
||||||
const [actTypeFilter, setActTypeFilter] = useState<ActivityType | 'ALL'>('ALL');
|
const [actTypeFilter, setActTypeFilter] = useState<ActivityType | 'ALL'>('ALL');
|
||||||
|
|
@ -158,8 +165,20 @@ export function ContactDetailPage() {
|
||||||
setActDateTo('');
|
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 (
|
return (
|
||||||
<div>
|
<div style={{ maxWidth: '960px', margin: '0 auto' }}>
|
||||||
{/* Back link */}
|
{/* Back link */}
|
||||||
<Link to="/crm/contacts" className={styles.backLink}>
|
<Link to="/crm/contacts" className={styles.backLink}>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -302,6 +321,11 @@ export function ContactDetailPage() {
|
||||||
|
|
||||||
{/* Right: Kontext */}
|
{/* Right: Kontext */}
|
||||||
<div className={styles.infoGrid}>
|
<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.infoLabel}>Unternehmen</span>
|
||||||
<span className={styles.infoValue}>
|
<span className={styles.infoValue}>
|
||||||
{contact.company ? (
|
{contact.company ? (
|
||||||
|
|
@ -318,6 +342,8 @@ export function ContactDetailPage() {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
) : contact.companyName ? (
|
||||||
|
<span>{contact.companyName}</span>
|
||||||
) : <span className={styles.empty}>—</span>}
|
) : <span className={styles.empty}>—</span>}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|
@ -331,6 +357,20 @@ export function ContactDetailPage() {
|
||||||
{contact.department ?? <span className={styles.empty}>—</span>}
|
{contact.department ?? <span className={styles.empty}>—</span>}
|
||||||
</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 && (
|
{contact.birthday && (
|
||||||
<>
|
<>
|
||||||
<span className={styles.infoLabel}>Geburtsdatum</span>
|
<span className={styles.infoLabel}>Geburtsdatum</span>
|
||||||
|
|
@ -347,29 +387,19 @@ export function ContactDetailPage() {
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Address — full-width below sub-columns */}
|
{/* 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.addressRow}>
|
||||||
<div className={styles.infoGrid}>
|
<div className={styles.infoGrid}>
|
||||||
<span className={styles.infoLabel}>Adresse</span>
|
<span className={styles.infoLabel}>Adresse</span>
|
||||||
<span className={styles.infoValue}>
|
<span className={styles.infoValue}>
|
||||||
{contact.street && <>{contact.street}<br /></>}
|
{contact.street && <>{contact.street}<br /></>}
|
||||||
{contact.zip} {contact.city}
|
{contact.zip} {contact.city}
|
||||||
{contact.country && contact.country !== 'DE' && <>, {contact.country}</>}
|
{contact.state && <>, {contact.state}</>}
|
||||||
|
{contact.country && contact.country !== 'DE' && <><br />{contact.country}</>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -674,44 +704,128 @@ export function ContactDetailPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Microsoft 365 ── */}
|
{/* ── Outlook Daten ── */}
|
||||||
{contact.email && (
|
{contact.email && (
|
||||||
<div className={styles.card} style={{ marginTop: '1.5rem' }}>
|
<div className={styles.card} style={{ marginTop: '1.5rem' }}>
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
{/* Karten-Header: Titel + Outlook-Push-Button + Toggle */}
|
||||||
<h2 className={styles.cardTitle} style={{ margin: '0 0 0.75rem' }}>
|
<div
|
||||||
Microsoft 365
|
style={{
|
||||||
</h2>
|
display: 'flex',
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', borderBottom: '1px solid var(--color-border)', paddingBottom: '0' }}>
|
alignItems: 'center',
|
||||||
{(['emails', 'calendar', 'tasks'] as M365Tab[]).map((tab) => {
|
justifyContent: 'space-between',
|
||||||
const labels: Record<M365Tab, string> = { emails: 'E-Mails', calendar: 'Kalender', tasks: 'Aufgaben' };
|
gap: '0.75rem',
|
||||||
return (
|
}}
|
||||||
<button
|
>
|
||||||
key={tab}
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setM365Tab(tab)}
|
onClick={() => setOutlookExpanded((v) => !v)}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.375rem 0.75rem',
|
display: 'flex',
|
||||||
background: 'transparent',
|
alignItems: 'center',
|
||||||
border: 'none',
|
gap: '0.5rem',
|
||||||
borderBottom: m365Tab === tab ? '2px solid var(--color-primary)' : '2px solid transparent',
|
background: 'transparent',
|
||||||
fontSize: '0.875rem',
|
border: 'none',
|
||||||
fontWeight: m365Tab === tab ? 600 : 400,
|
cursor: 'pointer',
|
||||||
color: m365Tab === tab ? 'var(--color-primary)' : 'var(--color-text-secondary)',
|
padding: 0,
|
||||||
cursor: 'pointer',
|
color: 'var(--color-text)',
|
||||||
marginBottom: '-1px',
|
}}
|
||||||
transition: 'all 0.15s',
|
>
|
||||||
}}
|
<h2 className={styles.cardTitle} style={{ margin: 0 }}>
|
||||||
>
|
Outlook Daten
|
||||||
{labels[tab]}
|
</h2>
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{m365Tab === 'emails' && <EmailsTab contactId={contact.id} />}
|
{/* Tabs + Inhalt (nur wenn ausgeklappt) */}
|
||||||
{m365Tab === 'calendar' && <CalendarTab contactId={contact.id} />}
|
{outlookExpanded && (
|
||||||
{m365Tab === 'tasks' && <TasksTab contactId={contact.id} />}
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue