feat(frontend+core): Dashboard Kontakte-Tab (O365) + Admin Logo-/Sidebar-Breite

Dashboard Kontakte-Tab:
- DashboardContactsTab.tsx + CSS: O365-Kontakte als Visitenkarten oder Liste
- Kachelansicht (auto-fill Grid) + Listenansicht (6-spaltig) umschaltbar
- Suchfeld (Name, E-Mail, Firma) mit Live-Filter
- Klick öffnet Detail-Modal mit allen Kontaktdaten (E-Mail/Telefon als Links)
- CRM-Import-Button: mappt M365Contact → CreateContactPayload, importiert direkt
- Nicht verbunden / Laden / Fehler States
- DashboardPage: ComingSoonTab entfernt, DashboardContactsTab eingebunden

Admin Branding — Logo-Breite + Sidebar-Breite:
- settings.controller.ts: logoWidth + sidebarWidth in GET/POST Branding
- AdminCustomizePage: Slider Logo-Breite (40–240px) + Sidebar-Breite (200–360px)
  mit Live-Vorschau (skalierte Mini-Sidebar)
- AppLayout: Logo-maxWidth aus branding.logoWidth; Sidebar width + main marginLeft
  dynamisch aus branding.sidebarWidth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-13 14:03:45 +01:00
parent d9c6240a3e
commit 3d75a7f9de
6 changed files with 1031 additions and 25 deletions

View file

@ -145,19 +145,23 @@ export class SettingsController {
async getBranding(): Promise<{
logo: string | null;
sidebarColor: string | null;
logoWidth: number | null;
sidebarWidth: number | null;
}> {
const raw = await this.redis.get(BRANDING_LOGO_KEY);
if (!raw) return { logo: null, sidebarColor: null };
if (!raw) return { logo: null, sidebarColor: null, logoWidth: null, sidebarWidth: null };
try {
const data = JSON.parse(raw);
return {
logo: data.logo || null,
sidebarColor: data.sidebarColor || null,
logoWidth: typeof data.logoWidth === 'number' ? data.logoWidth : null,
sidebarWidth: typeof data.sidebarWidth === 'number' ? data.sidebarWidth : null,
};
} catch {
// Legacy: nur Logo als String
return { logo: raw, sidebarColor: null };
return { logo: raw, sidebarColor: null, logoWidth: null, sidebarWidth: null };
}
}
@ -170,15 +174,30 @@ export class SettingsController {
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Branding-Einstellungen speichern (Admin)' })
async saveBranding(
@Body() body: { logo?: string | null; sidebarColor?: string | null },
@Body() body: {
logo?: string | null;
sidebarColor?: string | null;
logoWidth?: number | null;
sidebarWidth?: number | null;
},
): Promise<{ success: boolean }> {
if (body.logo && body.logo.length > 500_000) {
throw new BadRequestException('Logo darf maximal 500KB gross sein');
}
// Werte-Grenzen
const logoWidth = typeof body.logoWidth === 'number'
? Math.min(Math.max(Math.round(body.logoWidth), 40), 240)
: null;
const sidebarWidth = typeof body.sidebarWidth === 'number'
? Math.min(Math.max(Math.round(body.sidebarWidth), 200), 360)
: null;
const data = {
logo: body.logo || null,
sidebarColor: body.sidebarColor || null,
logoWidth,
sidebarWidth,
};
await this.redis.set(BRANDING_LOGO_KEY, JSON.stringify(data));

View file

@ -5,6 +5,8 @@ import api from '../api/client';
interface BrandingData {
logo: string | null;
sidebarColor: string | null;
logoWidth: number | null;
sidebarWidth: number | null;
}
const SIDEBAR_PRESETS = [
@ -63,6 +65,8 @@ export function AdminCustomizePage() {
const fileInputRef = useRef<HTMLInputElement>(null);
const [logo, setLogo] = useState<string | null>(null);
const [sidebarColor, setSidebarColor] = useState<string>('#1e293b');
const [logoWidth, setLogoWidth] = useState<number>(160);
const [sidebarWidth, setSidebarWidth] = useState<number>(240);
const [hasChanges, setHasChanges] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
@ -78,6 +82,8 @@ export function AdminCustomizePage() {
if (data) {
setLogo(data.logo);
setSidebarColor(data.sidebarColor || '#1e293b');
setLogoWidth(data.logoWidth ?? 160);
setSidebarWidth(data.sidebarWidth ?? 240);
setHasChanges(false);
}
}, [data]);
@ -86,6 +92,8 @@ export function AdminCustomizePage() {
mutationFn: async (branding: {
logo: string | null;
sidebarColor: string;
logoWidth: number;
sidebarWidth: number;
}) => {
const res = await api.post('/settings/branding', branding);
return res.data;
@ -129,7 +137,7 @@ export function AdminCustomizePage() {
};
const handleSave = () => {
saveMutation.mutate({ logo, sidebarColor });
saveMutation.mutate({ logo, sidebarColor, logoWidth, sidebarWidth });
};
return (
@ -169,6 +177,7 @@ export function AdminCustomizePage() {
SVG mit transparentem Hintergrund, max. 500KB.
</p>
{/* Vorschau + Upload */}
<div
style={{
display: 'flex',
@ -188,6 +197,7 @@ export function AdminCustomizePage() {
justifyContent: 'center',
overflow: 'hidden',
background: sidebarColor,
flexShrink: 0,
}}
>
{logo ? (
@ -195,7 +205,7 @@ export function AdminCustomizePage() {
src={logo}
alt="Logo"
style={{
maxWidth: '100%',
maxWidth: `${Math.round(logoWidth * 0.72)}px`,
maxHeight: '100%',
objectFit: 'contain',
}}
@ -242,6 +252,29 @@ export function AdminCustomizePage() {
)}
</div>
</div>
{/* Logo-Breite Slider */}
<div>
<label style={labelStyle}>
Logo-Breite: <strong>{logoWidth}px</strong>
</label>
<input
type="range"
min={40}
max={240}
step={10}
value={logoWidth}
onChange={(e) => {
setLogoWidth(Number(e.target.value));
setHasChanges(true);
}}
style={{ width: '100%', maxWidth: 320, display: 'block', marginBottom: '0.25rem' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', maxWidth: 320, fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
<span>40px</span>
<span>240px</span>
</div>
</div>
</div>
{/* Sidebar-Farbe */}
@ -414,6 +447,70 @@ export function AdminCustomizePage() {
</div>
</div>
{/* Sidebar-Breite */}
<div style={cardStyle}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>
Sidebar-Breite
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginBottom: '1rem' }}>
Breite der linken Menü-Leiste im ausgeklappten Zustand.
</p>
{/* Slider + Vorschau */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '2rem', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 280px' }}>
<label style={labelStyle}>
Breite: <strong>{sidebarWidth}px</strong>
</label>
<input
type="range"
min={200}
max={360}
step={10}
value={sidebarWidth}
onChange={(e) => {
setSidebarWidth(Number(e.target.value));
setHasChanges(true);
}}
style={{ width: '100%', maxWidth: 320, display: 'block', marginBottom: '0.25rem' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', maxWidth: 320, fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
<span>200px (schmal)</span>
<span>360px (breit)</span>
</div>
</div>
{/* Sidebar-Vorschau skaliert */}
<div
style={{
width: Math.round(sidebarWidth * 0.45),
height: 100,
borderRadius: 'var(--radius-sm)',
background: sidebarColor,
border: '1px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
padding: '0.5rem 0.625rem',
gap: '0.35rem',
overflow: 'hidden',
flexShrink: 0,
transition: 'width 0.15s',
}}
>
{/* Mini-Logo */}
<div style={{ fontSize: '0.5rem', color: '#60a5fa', fontWeight: 700, letterSpacing: 1, marginBottom: '0.25rem' }}>
{logo ? (
<img src={logo} alt="" style={{ maxWidth: Math.round(logoWidth * 0.3), maxHeight: 12, objectFit: 'contain' }} />
) : 'INSIGHT'}
</div>
{/* Mini Nav-Einträge */}
{[80, 65, 75, 55].map((w, i) => (
<div key={i} style={{ height: 5, width: `${w}%`, background: 'rgba(255,255,255,0.2)', borderRadius: 2 }} />
))}
</div>
</div>
</div>
{/* Speichern */}
<div
style={{

View file

@ -150,12 +150,16 @@ export function AppLayout() {
const { data: branding } = useQuery<{
logo: string | null;
sidebarColor: string | null;
logoWidth: number | null;
sidebarWidth: number | null;
}>({
queryKey: ['settings', 'branding'],
queryFn: async () => {
const res = await api.get<{
logo: string | null;
sidebarColor: string | null;
logoWidth: number | null;
sidebarWidth: number | null;
}>('/settings/branding');
return res.data;
},
@ -172,11 +176,10 @@ export function AppLayout() {
{/* Sidebar */}
<aside
className={`${styles.sidebar} ${collapsed ? styles.sidebarCollapsed : ''}`}
style={
branding?.sidebarColor
? { background: branding.sidebarColor }
: undefined
}
style={{
...(branding?.sidebarColor ? { background: branding.sidebarColor } : {}),
...(!collapsed && branding?.sidebarWidth ? { width: branding.sidebarWidth } : {}),
}}
>
<div className={styles.brand}>
{!collapsed &&
@ -186,7 +189,7 @@ export function AppLayout() {
alt="Logo"
style={{
maxHeight: 44,
maxWidth: 160,
maxWidth: branding.logoWidth ?? 160,
objectFit: 'contain',
}}
/>
@ -578,7 +581,7 @@ export function AppLayout() {
<main
className={styles.main}
style={{
marginLeft: collapsed ? 60 : undefined,
marginLeft: collapsed ? 60 : (branding?.sidebarWidth ?? undefined),
}}
>
{/* Topbar: Profil + Logout + Modiwahl oben rechts */}

View file

@ -0,0 +1,485 @@
/* ============================================================
DashboardContactsTab O365-Kontakte mit Kachel-/Listenansicht
============================================================ */
.root {
/* Nutzt das Padding von .mainContent */
}
/* ── Toolbar ─────────────────────────────────────────────────────────────────── */
.toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}
.searchWrap {
position: relative;
flex: 1;
min-width: 200px;
max-width: 400px;
display: flex;
align-items: center;
}
.searchIcon {
position: absolute;
left: 0.625rem;
color: var(--color-text-muted);
pointer-events: none;
}
.searchInput {
width: 100%;
padding: 0.5rem 2rem 0.5rem 2.25rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 0.875rem;
color: var(--color-text);
outline: none;
transition: border-color 0.15s;
}
.searchInput:focus {
border-color: var(--color-primary);
}
.searchClear {
position: absolute;
right: 0.5rem;
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
padding: 0;
transition: color 0.1s;
}
.searchClear:hover {
color: var(--color-text);
}
/* Ansichts-Toggle */
.viewToggle {
display: flex;
gap: 2px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 2px;
}
.viewBtn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 3px;
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.15s;
}
.viewBtn:hover {
color: var(--color-text);
background: var(--color-bg);
}
.viewBtnActive {
background: var(--color-primary);
color: white !important;
}
.count {
font-size: 0.8125rem;
color: var(--color-text-muted);
margin-left: auto;
}
/* ── Kachelansicht ────────────────────────────────────────────────────────────── */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(195px, 1fr));
gap: 1rem;
}
.card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.25rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.2rem;
cursor: pointer;
transition: all 0.15s;
outline: none;
}
.card:hover {
border-color: var(--color-primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
.card:focus-visible {
border-color: var(--color-primary);
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.cardAvatar {
width: 52px;
height: 52px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.125rem;
font-weight: 700;
color: white;
margin-bottom: 0.5rem;
flex-shrink: 0;
}
.cardName {
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text);
word-break: break-word;
line-height: 1.3;
}
.cardJobTitle {
font-size: 0.75rem;
color: var(--color-text-secondary);
font-style: italic;
}
.cardCompany {
font-size: 0.8125rem;
color: var(--color-text-secondary);
font-weight: 500;
}
.cardEmail {
font-size: 0.6875rem;
color: var(--color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
margin-top: 0.25rem;
}
.cardPhone {
font-size: 0.6875rem;
color: var(--color-text-muted);
}
/* ── Listenansicht ────────────────────────────────────────────────────────────── */
.listHeader {
display: grid;
grid-template-columns: 36px 1.5fr 1fr 1fr 1.5fr 1fr;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 0.25rem;
}
.listHeaderCell {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.list {
display: flex;
flex-direction: column;
}
.row {
display: grid;
grid-template-columns: 36px 1.5fr 1fr 1fr 1.5fr 1fr;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.1s;
outline: none;
}
.row:hover {
background: var(--color-bg-card);
}
.row:focus-visible {
background: var(--color-bg-card);
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.rowAvatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6875rem;
font-weight: 700;
color: white;
flex-shrink: 0;
}
.rowName {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowMeta {
font-size: 0.8125rem;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Leerer Zustand ────────────────────────────────────────────────────────────── */
.empty {
text-align: center;
padding: 3rem;
color: var(--color-text-muted);
font-size: 0.9375rem;
}
/* ── Status-Seiten (nicht verbunden, laden, Fehler) ──────────────────────────── */
.state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 5rem 2rem;
text-align: center;
}
.stateIcon {
font-size: 3rem;
margin-bottom: 0.75rem;
}
.stateTitle {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text);
margin: 0;
}
.stateSub {
font-size: 0.875rem;
color: var(--color-text-muted);
max-width: 360px;
margin: 0;
}
.stateCenter {
text-align: center;
padding: 3rem;
color: var(--color-text-muted);
font-size: 0.9375rem;
}
/* ── Modal ────────────────────────────────────────────────────────────────────── */
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(2px);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.modal {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg, var(--radius-md));
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 460px;
position: relative;
padding: 1.75rem;
}
.modalClose {
position: absolute;
top: 1rem;
right: 1rem;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.15s;
}
.modalClose:hover {
background: var(--color-bg);
color: var(--color-text);
}
.modalHeader {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.25rem;
padding-right: 2.25rem;
}
.modalAvatar {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 700;
color: white;
flex-shrink: 0;
}
.modalName {
font-size: 1.125rem;
font-weight: 700;
color: var(--color-text);
margin: 0 0 0.2rem;
line-height: 1.3;
}
.modalJobTitle {
font-size: 0.875rem;
color: var(--color-text-secondary);
font-style: italic;
margin-bottom: 0.1rem;
}
.modalCompany {
font-size: 0.875rem;
color: var(--color-text-secondary);
font-weight: 500;
}
.modalDetails {
display: flex;
flex-direction: column;
gap: 0.625rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--color-bg);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.detailRow {
display: flex;
align-items: center;
gap: 0.75rem;
}
.detailLabel {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
min-width: 72px;
flex-shrink: 0;
}
.detailValue {
font-size: 0.875rem;
color: var(--color-text);
text-decoration: none;
word-break: break-all;
}
.detailValue:hover {
color: var(--color-primary);
text-decoration: underline;
}
.detailEmpty {
font-size: 0.875rem;
color: var(--color-text-muted);
font-style: italic;
margin: 0;
}
.modalFooter {
display: flex;
justify-content: flex-end;
}
.importBtn {
padding: 0.625rem 1.5rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
min-width: 150px;
text-align: center;
}
.importBtn:hover:not(:disabled) {
filter: brightness(1.1);
}
.importBtn:disabled {
cursor: not-allowed;
opacity: 0.9;
}
.importBtnSuccess {
background: #22c55e;
}
.importBtnError {
background: #ef4444;
cursor: pointer !important;
}

View file

@ -0,0 +1,411 @@
import { useState } from 'react';
import { useOffice365Contacts, useIntegrations, useCreateContact } from '../crm/hooks';
import type { M365Contact, CreateContactPayload } from '../crm/types';
import styles from './DashboardContactsTab.module.css';
// ── Hilfsfunktionen ────────────────────────────────────────────────────────────
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (
(parts[0][0] ?? '') + (parts[parts.length - 1][0] ?? '')
).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
const AVATAR_COLORS = [
'#6366f1', '#8b5cf6', '#ec4899', '#f43f5e',
'#f97316', '#eab308', '#22c55e', '#14b8a6',
'#3b82f6', '#0ea5e9',
];
function getAvatarColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}
// ── Kontakt-Kachel ─────────────────────────────────────────────────────────────
function ContactCard({
contact,
onClick,
}: {
contact: M365Contact;
onClick: () => void;
}) {
const initials = getInitials(contact.displayName);
const color = getAvatarColor(contact.displayName);
const email = contact.emailAddresses[0]?.address ?? null;
const phone = contact.mobilePhone ?? contact.businessPhones[0] ?? null;
return (
<div
className={styles.card}
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onClick();
}}
>
<div className={styles.cardAvatar} style={{ background: color }}>
{initials}
</div>
<div className={styles.cardName}>{contact.displayName}</div>
{contact.jobTitle && <div className={styles.cardJobTitle}>{contact.jobTitle}</div>}
{contact.companyName && <div className={styles.cardCompany}>{contact.companyName}</div>}
{email && <div className={styles.cardEmail}>{email}</div>}
{phone && <div className={styles.cardPhone}>{phone}</div>}
</div>
);
}
// ── Kontakt-Zeile ──────────────────────────────────────────────────────────────
function ContactRow({
contact,
onClick,
}: {
contact: M365Contact;
onClick: () => void;
}) {
const initials = getInitials(contact.displayName);
const color = getAvatarColor(contact.displayName);
const email = contact.emailAddresses[0]?.address ?? null;
const phone = contact.mobilePhone ?? contact.businessPhones[0] ?? null;
return (
<div
className={styles.row}
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onClick();
}}
>
<div className={styles.rowAvatar} style={{ background: color }}>
{initials}
</div>
<div className={styles.rowName}>{contact.displayName}</div>
<div className={styles.rowMeta}>{contact.companyName ?? '—'}</div>
<div className={styles.rowMeta}>{contact.jobTitle ?? '—'}</div>
<div className={styles.rowMeta}>{email ?? '—'}</div>
<div className={styles.rowMeta}>{phone ?? '—'}</div>
</div>
);
}
// ── Detail-Modal ───────────────────────────────────────────────────────────────
type ImportStatus = 'idle' | 'loading' | 'success' | 'error';
function ContactModal({
contact,
onClose,
}: {
contact: M365Contact;
onClose: () => void;
}) {
const createContact = useCreateContact();
const [importStatus, setImportStatus] = useState<ImportStatus>('idle');
const initials = getInitials(contact.displayName);
const color = getAvatarColor(contact.displayName);
const email = contact.emailAddresses[0]?.address ?? null;
const handleImport = async () => {
if (importStatus === 'loading' || importStatus === 'success') return;
if (importStatus === 'error') {
setImportStatus('idle');
return;
}
setImportStatus('loading');
const parts = contact.displayName.trim().split(/\s+/);
const firstName = parts[0] ?? '';
const lastName = parts.length > 1 ? parts.slice(1).join(' ') : undefined;
const payload: CreateContactPayload = {
type: 'PERSON',
firstName,
lastName,
email: email ?? undefined,
mobile: contact.mobilePhone ?? undefined,
phone: contact.businessPhones[0] ?? undefined,
position: contact.jobTitle ?? undefined,
companyName: contact.companyName ?? undefined,
};
try {
await createContact.mutateAsync(payload);
setImportStatus('success');
} catch {
setImportStatus('error');
}
};
const importLabel =
importStatus === 'loading' ? 'Importiere…'
: importStatus === 'success' ? '✓ Importiert'
: importStatus === 'error' ? '✕ Fehler — Erneut versuchen'
: 'CRM Import';
return (
<div
className={styles.backdrop}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className={styles.modal}>
{/* Schliessen */}
<button className={styles.modalClose} onClick={onClose} title="Schließen">
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M1 1l10 10M11 1L1 11" />
</svg>
</button>
{/* Kopfzeile */}
<div className={styles.modalHeader}>
<div className={styles.modalAvatar} style={{ background: color }}>
{initials}
</div>
<div>
<h2 className={styles.modalName}>{contact.displayName}</h2>
{contact.jobTitle && <div className={styles.modalJobTitle}>{contact.jobTitle}</div>}
{contact.companyName && <div className={styles.modalCompany}>{contact.companyName}</div>}
</div>
</div>
{/* Kontaktdetails */}
<div className={styles.modalDetails}>
{contact.emailAddresses.map((ea, i) =>
ea.address ? (
<div key={i} className={styles.detailRow}>
<span className={styles.detailLabel}>
{i === 0 ? 'E-Mail' : `E-Mail ${i + 1}`}
</span>
<a href={`mailto:${ea.address}`} className={styles.detailValue}>
{ea.address}
</a>
</div>
) : null,
)}
{contact.mobilePhone && (
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Mobil</span>
<a href={`tel:${contact.mobilePhone}`} className={styles.detailValue}>
{contact.mobilePhone}
</a>
</div>
)}
{contact.businessPhones.map((p, i) => (
<div key={i} className={styles.detailRow}>
<span className={styles.detailLabel}>
{contact.businessPhones.length > 1 ? `Telefon ${i + 1}` : 'Telefon'}
</span>
<a href={`tel:${p}`} className={styles.detailValue}>
{p}
</a>
</div>
))}
{/* Kein Kontaktdetail vorhanden */}
{!email &&
!contact.mobilePhone &&
contact.businessPhones.length === 0 && (
<p className={styles.detailEmpty}>Keine Kontaktdaten hinterlegt.</p>
)}
</div>
{/* Footer: CRM Import */}
<div className={styles.modalFooter}>
<button
className={`${styles.importBtn}
${importStatus === 'success' ? styles.importBtnSuccess : ''}
${importStatus === 'error' ? styles.importBtnError : ''}`}
onClick={handleImport}
disabled={importStatus === 'loading' || importStatus === 'success'}
>
{importLabel}
</button>
</div>
</div>
</div>
);
}
// ── Hauptkomponente ────────────────────────────────────────────────────────────
export function DashboardContactsTab() {
const { data: integrationsData } = useIntegrations();
const { data, isLoading, isError } = useOffice365Contacts();
const [search, setSearch] = useState('');
const [viewMode, setViewMode] = useState<'cards' | 'list'>('cards');
const [selected, setSelected] = useState<M365Contact | null>(null);
const isConnected =
integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false;
/* ── Nicht verbunden ── */
if (!isConnected) {
return (
<div className={styles.state}>
<div className={styles.stateIcon}>📇</div>
<p className={styles.stateTitle}>Microsoft 365 nicht verbunden</p>
<p className={styles.stateSub}>
Verbinde dein Microsoft-365-Konto im Profil, um deine Kontakte zu sehen.
</p>
</div>
);
}
/* ── Laden ── */
if (isLoading) {
return <div className={styles.stateCenter}>Kontakte werden geladen</div>;
}
/* ── Fehler ── */
if (isError || !data) {
return (
<div className={styles.stateCenter} style={{ color: 'var(--color-error, #ef4444)' }}>
Kontakte konnten nicht geladen werden.
</div>
);
}
const contacts = data.data ?? [];
const q = search.trim().toLowerCase();
const filtered = q
? contacts.filter(
(c) =>
c.displayName.toLowerCase().includes(q) ||
(c.companyName ?? '').toLowerCase().includes(q) ||
c.emailAddresses.some((ea) => (ea.address ?? '').toLowerCase().includes(q)),
)
: contacts;
return (
<div className={styles.root}>
{/* Toolbar */}
<div className={styles.toolbar}>
{/* Suche */}
<div className={styles.searchWrap}>
<svg
className={styles.searchIcon}
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<circle cx="6" cy="6" r="4" />
<path d="M9 9l3 3" />
</svg>
<input
type="text"
className={styles.searchInput}
placeholder="Name, E-Mail oder Firma suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
className={styles.searchClear}
onClick={() => setSearch('')}
title="Suche leeren"
>
×
</button>
)}
</div>
{/* Ansicht-Toggle */}
<div className={styles.viewToggle}>
<button
className={`${styles.viewBtn} ${viewMode === 'cards' ? styles.viewBtnActive : ''}`}
onClick={() => setViewMode('cards')}
title="Kachelansicht"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<rect x="0" y="0" width="6" height="6" rx="1" />
<rect x="8" y="0" width="6" height="6" rx="1" />
<rect x="0" y="8" width="6" height="6" rx="1" />
<rect x="8" y="8" width="6" height="6" rx="1" />
</svg>
</button>
<button
className={`${styles.viewBtn} ${viewMode === 'list' ? styles.viewBtnActive : ''}`}
onClick={() => setViewMode('list')}
title="Listenansicht"
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<path d="M1 3h12M1 7h12M1 11h12" />
</svg>
</button>
</div>
<span className={styles.count}>{filtered.length} Kontakte</span>
</div>
{/* Listenansicht-Header */}
{viewMode === 'list' && (
<div className={styles.listHeader}>
<div /> {/* Avatar-Platz */}
<div className={styles.listHeaderCell}>Name</div>
<div className={styles.listHeaderCell}>Firma</div>
<div className={styles.listHeaderCell}>Jobtitel</div>
<div className={styles.listHeaderCell}>E-Mail</div>
<div className={styles.listHeaderCell}>Telefon</div>
</div>
)}
{/* Kacheln oder Liste */}
<div className={viewMode === 'cards' ? styles.grid : styles.list}>
{filtered.map((c) =>
viewMode === 'cards' ? (
<ContactCard key={c.id} contact={c} onClick={() => setSelected(c)} />
) : (
<ContactRow key={c.id} contact={c} onClick={() => setSelected(c)} />
),
)}
</div>
{/* Keine Ergebnisse */}
{filtered.length === 0 && (
<div className={styles.empty}>Keine Kontakte gefunden.</div>
)}
{/* Detail-Modal */}
{selected && (
<ContactModal contact={selected} onClose={() => setSelected(null)} />
)}
</div>
);
}

View file

@ -6,6 +6,7 @@ import { AnalogClock } from '../components/AnalogClock';
import { DashboardEmailTab } from './DashboardEmailTab';
import { DashboardCalendarTab, DayAgenda } from './DashboardCalendarTab';
import { DashboardTasksTab } from './DashboardTasksTab';
import { DashboardContactsTab } from './DashboardContactsTab';
import {
useIntegrations,
useOffice365CalendarRange,
@ -685,16 +686,6 @@ function HomeTab({
);
}
function ComingSoonTab({ label }: { label: string }) {
return (
<div className={styles.comingSoon}>
<span className={styles.comingSoonIcon}>🚧</span>
<p className={styles.comingSoonTitle}>{label}</p>
<p className={styles.comingSoonSub}>Inhalt folgt in Kürze.</p>
</div>
);
}
// ── Main ──────────────────────────────────────────────────────────────────────
export function DashboardPage() {
@ -736,7 +727,7 @@ export function DashboardPage() {
{activeTab === 'emails' && <DashboardEmailTab />}
{activeTab === 'calendar' && <DashboardCalendarTab />}
{activeTab === 'tasks' && <DashboardTasksTab />}
{activeTab === 'contacts' && <ComingSoonTab label="Kontakte" />}
{activeTab === 'contacts' && <DashboardContactsTab />}
</div>
</div>
);