feat(frontend): E-Mail-Popup, Aktivitaeten-Zeitstrahl + Profil in Tab-Leiste

- EmailsTab: Outlook-aehnlicher Detail-Popup beim Klick auf E-Mail (Von/An/Datum/Anhang-Meta, Body-Vorschau, In Kontakt speichern als EMAIL-Aktivitaet)
- Neues EmailsTab.module.css fuer kompakte Liste und Modal
- ContactDetailPage: Aktivitaeten-Filterleiste (Typ + Zeitraum Von/Bis)
- ContactDetailPage: Zeitstrahl mit vertikaler Verbindungslinie, farbigen Typ-Badges (Note/Call/Email/Meeting/Task/FollowUp)
- DashboardPage: Profil-Bereich (Theme-Schalter, Avatar, Name, Logout) in Tab-Leiste integriert und leicht farblich abgesetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-13 15:51:10 +01:00
parent 17a81c97ef
commit 2af54246c8
6 changed files with 1050 additions and 90 deletions

View file

@ -191,22 +191,99 @@
font-weight: 500;
}
/* ---- Timeline ---- */
/* ---- Aktivitäten-Filter ---- */
.activityFilterBar {
display: flex;
align-items: center;
gap: 0.625rem;
flex-wrap: wrap;
padding: 0.625rem 0.875rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
margin-bottom: 1rem;
}
.activityFilterSelect {
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
cursor: pointer;
height: 30px;
}
.activityFilterDate {
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
height: 30px;
width: 130px;
}
.activityFilterLabel {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.activityFilterReset {
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.12s;
white-space: nowrap;
}
.activityFilterReset:hover {
color: var(--color-text);
border-color: var(--color-text-muted);
}
.activityFilterCount {
margin-left: auto;
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
}
/* ---- Timeline (Zeitstrahl) ---- */
.timeline {
position: relative;
display: flex;
flex-direction: column;
gap: 0;
}
/* Verbindungslinie links */
.timeline::before {
content: '';
position: absolute;
left: 15px; /* Mitte des 32px Icons */
top: 24px;
bottom: 16px;
width: 2px;
background: var(--color-border);
pointer-events: none;
}
.timelineItem {
position: relative;
display: flex;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-border);
gap: 0.875rem;
padding: 0.625rem 0;
}
.timelineItem:last-child {
border-bottom: none;
padding-bottom: 0;
}
.timelineIcon {
@ -214,35 +291,81 @@
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-bg);
border: 1px solid var(--color-border);
background: var(--color-bg-card);
border: 2px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
transition: border-color 0.15s;
}
/* Farbige Icon-Kreise pro Typ */
.timelineIconNote { border-color: #94a3b8; color: #64748b; }
.timelineIconCall { border-color: #22c55e; color: #16a34a; }
.timelineIconEmail { border-color: #3b82f6; color: #2563eb; }
.timelineIconMeeting { border-color: #a855f7; color: #9333ea; }
.timelineIconTask { border-color: #f59e0b; color: #d97706; }
.timelineIconFollowup { border-color: #ef4444; color: #dc2626; }
.timelineContent {
flex: 1;
min-width: 0;
padding-top: 0.1875rem;
}
.timelineSubject {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
line-height: 1.35;
}
.timelineMeta {
display: flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.1875rem;
}
.timelineTypeBadge {
display: inline-flex;
align-items: center;
padding: 0.0625rem 0.4375rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
white-space: nowrap;
}
.timelineBadgeNote { background: #f1f5f9; color: #64748b; }
.timelineBadgeCall { background: #dcfce7; color: #16a34a; }
.timelineBadgeEmail { background: #dbeafe; color: #2563eb; }
.timelineBadgeMeeting { background: #f3e8ff; color: #9333ea; }
.timelineBadgeTask { background: #fef9c3; color: #d97706; }
.timelineBadgeFollowup { background: #fee2e2; color: #dc2626; }
.timelineDate {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: 0.125rem;
}
.timelineDesc {
font-size: 0.8125rem;
color: var(--color-text-secondary);
margin-top: 0.375rem;
margin-top: 0.3125rem;
white-space: pre-wrap;
line-height: 1.45;
}
.timelineEmpty {
text-align: center;
padding: 1.5rem 0;
color: var(--color-text-muted);
font-size: 0.875rem;
}
/* ---- Notes ---- */

View file

@ -119,6 +119,11 @@ export function ContactDetailPage() {
const [isDeleteOpen, setDeleteOpen] = useState(false);
const [m365Tab, setM365Tab] = useState<M365Tab>('emails');
// Aktivitäten-Filter
const [actTypeFilter, setActTypeFilter] = useState<ActivityType | 'ALL'>('ALL');
const [actDateFrom, setActDateFrom] = useState('');
const [actDateTo, setActDateTo] = useState('');
if (isLoading) return <p>Laden</p>;
if (error || !data)
return (
@ -128,9 +133,31 @@ export function ContactDetailPage() {
);
const contact = data.data;
const activities: Activity[] = contact.activities ?? [];
const allActivities: Activity[] = contact.activities ?? [];
const deals = dealsData?.data ?? [];
// Gefilterte Aktivitäten
const activities = allActivities.filter((act) => {
if (actTypeFilter !== 'ALL' && act.type !== actTypeFilter) return false;
if (actDateFrom) {
const actDate = new Date(act.createdAt).toISOString().slice(0, 10);
if (actDate < actDateFrom) return false;
}
if (actDateTo) {
const actDate = new Date(act.createdAt).toISOString().slice(0, 10);
if (actDate > actDateTo) return false;
}
return true;
});
const isFilterActive = actTypeFilter !== 'ALL' || actDateFrom !== '' || actDateTo !== '';
function resetFilters() {
setActTypeFilter('ALL');
setActDateFrom('');
setActDateTo('');
}
return (
<div>
{/* Back link */}
@ -495,6 +522,7 @@ export function ContactDetailPage() {
{/* ── Aktivitäten (full width below) ── */}
<div className={styles.card} style={{ marginTop: '1.5rem' }}>
{/* Karten-Header */}
<div
style={{
display: 'flex',
@ -505,6 +533,18 @@ export function ContactDetailPage() {
>
<h2 className={styles.cardTitle} style={{ margin: 0 }}>
Aktivitäten
{allActivities.length > 0 && (
<span
style={{
marginLeft: '0.5rem',
fontSize: '0.8125rem',
fontWeight: 400,
color: 'var(--color-text-muted)',
}}
>
({allActivities.length})
</span>
)}
</h2>
<button
onClick={() => setActivityOpen(true)}
@ -523,31 +563,113 @@ export function ContactDetailPage() {
</button>
</div>
{/* Filter-Leiste */}
{allActivities.length > 0 && (
<div className={styles.activityFilterBar}>
<span className={styles.activityFilterLabel}>Typ:</span>
<select
className={styles.activityFilterSelect}
value={actTypeFilter}
onChange={(e) =>
setActTypeFilter(e.target.value as ActivityType | 'ALL')
}
>
<option value="ALL">Alle</option>
{(Object.keys(ACTIVITY_TYPE_LABELS) as ActivityType[]).map(
(t) => (
<option key={t} value={t}>
{ACTIVITY_TYPE_LABELS[t]}
</option>
),
)}
</select>
<span className={styles.activityFilterLabel}>Von:</span>
<input
type="date"
className={styles.activityFilterDate}
value={actDateFrom}
onChange={(e) => setActDateFrom(e.target.value)}
/>
<span className={styles.activityFilterLabel}>Bis:</span>
<input
type="date"
className={styles.activityFilterDate}
value={actDateTo}
onChange={(e) => setActDateTo(e.target.value)}
/>
{isFilterActive && (
<button
type="button"
className={styles.activityFilterReset}
onClick={resetFilters}
>
× Filter zurücksetzen
</button>
)}
<span className={styles.activityFilterCount}>
{activities.length} von {allActivities.length}
</span>
</div>
)}
{/* Zeitstrahl */}
{activities.length === 0 ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>
Keine Aktivitäten vorhanden
<p className={styles.timelineEmpty}>
{isFilterActive
? 'Keine Aktivitäten für den gewählten Filter'
: 'Keine Aktivitäten vorhanden'}
</p>
) : (
<div className={styles.timeline}>
{activities.map((act) => (
<div key={act.id} className={styles.timelineItem}>
<div className={styles.timelineIcon}>
{activityIcon(act.type)}
</div>
<div className={styles.timelineContent}>
<div className={styles.timelineSubject}>{act.subject}</div>
<div className={styles.timelineMeta}>
{ACTIVITY_TYPE_LABELS[act.type]} &middot;{' '}
{formatDate(act.createdAt)}
{activities.map((act) => {
const iconClass = {
NOTE: styles.timelineIconNote,
CALL: styles.timelineIconCall,
EMAIL: styles.timelineIconEmail,
MEETING: styles.timelineIconMeeting,
TASK: styles.timelineIconTask,
FOLLOWUP: styles.timelineIconFollowup,
}[act.type] ?? '';
const badgeClass = {
NOTE: styles.timelineBadgeNote,
CALL: styles.timelineBadgeCall,
EMAIL: styles.timelineBadgeEmail,
MEETING: styles.timelineBadgeMeeting,
TASK: styles.timelineBadgeTask,
FOLLOWUP: styles.timelineBadgeFollowup,
}[act.type] ?? '';
return (
<div key={act.id} className={styles.timelineItem}>
<div className={`${styles.timelineIcon} ${iconClass}`}>
{activityIcon(act.type)}
</div>
{act.description && (
<div className={styles.timelineDesc}>
{act.description}
<div className={styles.timelineContent}>
<div className={styles.timelineSubject}>{act.subject}</div>
<div className={styles.timelineMeta}>
<span
className={`${styles.timelineTypeBadge} ${badgeClass}`}
>
{ACTIVITY_TYPE_LABELS[act.type]}
</span>
<span className={styles.timelineDate}>
{formatDate(act.createdAt)}
</span>
</div>
)}
{act.description && (
<div className={styles.timelineDesc}>
{act.description}
</div>
)}
</div>
</div>
</div>
))}
);
})}
</div>
)}
</div>

View file

@ -0,0 +1,332 @@
/* ============================================================
EmailsTab E-Mail-Liste + Detail-Modal
============================================================ */
/* ---- E-Mail-Liste ---- */
.emailList {
display: flex;
flex-direction: column;
gap: 0.3125rem;
}
.emailItem {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-left-width: 3px;
border-left-color: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
text-align: left;
width: 100%;
min-width: 0;
}
.emailItem:hover {
background: var(--color-bg-subtle, var(--color-bg));
}
.emailItemUnread {
border-left-color: var(--color-primary);
}
.emailMeta {
flex: 1;
min-width: 0;
overflow: hidden;
}
.emailRow1 {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.125rem;
}
.emailSubject {
font-size: 0.8125rem;
font-weight: 400;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.emailSubjectUnread {
font-weight: 600;
}
.emailTime {
font-size: 0.75rem;
color: var(--color-text-muted);
flex-shrink: 0;
white-space: nowrap;
}
.emailFrom {
font-size: 0.75rem;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emailPreview {
font-size: 0.75rem;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 0.125rem;
opacity: 0.8;
}
/* ---- Status-Dots ---- */
.unreadDot {
flex-shrink: 0;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-primary);
margin-top: 0.4rem;
}
.readSpacer {
flex-shrink: 0;
width: 7px;
}
/* ---- Anhang-Badge ---- */
.attachBadge {
flex-shrink: 0;
font-size: 0.875rem;
margin-top: 0.125rem;
opacity: 0.7;
}
/* ============================================================
Detail-Modal
============================================================ */
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal {
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
border: 1px solid var(--color-border);
width: 100%;
max-width: 660px;
max-height: 82vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Modal-Header */
.modalHeader {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: flex-start;
gap: 0.75rem;
flex-shrink: 0;
}
.modalHeaderLeft {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.modalSubject {
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
word-break: break-word;
margin: 0;
line-height: 1.35;
}
.modalUnreadBadge {
display: inline-block;
padding: 0.125rem 0.5rem;
background: var(--color-primary);
color: white;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
align-self: flex-start;
}
.modalClose {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.375rem;
cursor: pointer;
border-radius: var(--radius-sm);
transition: color 0.12s, background 0.12s;
line-height: 1;
}
.modalClose:hover {
color: var(--color-text);
background: var(--color-bg);
}
/* Modal-Body */
.modalBody {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Meta-Tabelle (Von / An / Datum / Anhang) */
.metaTable {
display: grid;
grid-template-columns: 52px 1fr;
gap: 0.3125rem 0.625rem;
font-size: 0.8125rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.75rem;
}
.metaLabel {
color: var(--color-text-muted);
font-weight: 500;
padding-top: 0.0625rem;
}
.metaValue {
color: var(--color-text);
word-break: break-word;
}
/* Body-Vorschau */
.bodyPreview {
font-size: 0.875rem;
color: var(--color-text-secondary);
white-space: pre-wrap;
line-height: 1.65;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 1rem;
}
.bodyHint {
font-size: 0.75rem;
color: var(--color-text-muted);
text-align: center;
margin: 0;
}
/* Speichern-Bereich */
.saveSection {
border-top: 1px solid var(--color-border);
padding-top: 0.875rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.saveBtn {
padding: 0.375rem 0.875rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-sm);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.12s;
white-space: nowrap;
}
.saveBtn:disabled {
opacity: 0.65;
cursor: wait;
}
.saveBtnSuccess {
background: #16a34a;
}
.saveBtnError {
background: var(--color-error, #dc2626);
}
.saveMsg {
font-size: 0.75rem;
color: var(--color-text-muted);
}
/* Modal-Footer */
.modalFooter {
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-shrink: 0;
}
.modalFooterDate {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.outlookBtn {
padding: 0.375rem 0.875rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.8125rem;
color: var(--color-text-secondary);
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.375rem;
transition: all 0.12s;
}
.outlookBtn:hover {
background: var(--color-bg);
color: var(--color-text);
text-decoration: none;
}

View file

@ -1,6 +1,8 @@
import { useContactEmails, useIntegrations } from '../hooks';
import { useState } from 'react';
import { useContactEmails, useIntegrations, useCreateActivity } from '../hooks';
import { integrationsApi } from '../api';
import type { M365Email } from '../types';
import type { M365Email, M365EmailAddress } from '../types';
import styles from './EmailsTab.module.css';
interface Props {
contactId: string;
@ -16,19 +18,194 @@ function formatEmailDate(iso: string): string {
});
}
function formatEmailTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
if (d.toDateString() === now.toDateString()) {
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function recipientsText(
recipients: Array<{ emailAddress: M365EmailAddress }>,
): string {
return recipients
.map((r) => r.emailAddress.name ?? r.emailAddress.address ?? '—')
.join(', ');
}
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
/* ── Detail-Popup (Outlook-ähnlich) ───────────────────────────────────────── */
function EmailDetailModal({
email,
contactId,
onClose,
}: {
email: M365Email;
contactId: string;
onClose: () => void;
}) {
const createActivity = useCreateActivity();
const [saveState, setSaveState] = useState<SaveState>('idle');
const fromName =
email.from?.emailAddress?.name ?? email.from?.emailAddress?.address ?? '—';
const toText = email.toRecipients?.length
? recipientsText(email.toRecipients)
: '—';
async function handleSave() {
if (saveState === 'saved') return;
setSaveState('saving');
try {
await createActivity.mutateAsync({
contactId,
type: 'EMAIL',
subject: email.subject ?? '(kein Betreff)',
description: email.bodyPreview ?? undefined,
scheduledAt: email.receivedDateTime,
completedAt: email.receivedDateTime,
});
setSaveState('saved');
} catch {
setSaveState('error');
}
}
return (
<div className={styles.backdrop} onClick={onClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className={styles.modalHeader}>
<div className={styles.modalHeaderLeft}>
<h2 className={styles.modalSubject}>{email.subject ?? '(kein Betreff)'}</h2>
{!email.isRead && (
<span className={styles.modalUnreadBadge}>Ungelesen</span>
)}
</div>
<button
type="button"
className={styles.modalClose}
onClick={onClose}
aria-label="Schließen"
>
×
</button>
</div>
{/* Body */}
<div className={styles.modalBody}>
{/* Meta: Von / An / Datum / Anhang */}
<div className={styles.metaTable}>
<span className={styles.metaLabel}>Von</span>
<span className={styles.metaValue}>{fromName}</span>
<span className={styles.metaLabel}>An</span>
<span className={styles.metaValue}>{toText}</span>
<span className={styles.metaLabel}>Datum</span>
<span className={styles.metaValue}>{formatEmailDate(email.receivedDateTime)}</span>
{email.hasAttachments && (
<>
<span className={styles.metaLabel}>Anhang</span>
<span className={styles.metaValue}>📎 Anhänge vorhanden</span>
</>
)}
</div>
{/* Body-Vorschau */}
{email.bodyPreview ? (
<>
<div className={styles.bodyPreview}>{email.bodyPreview}</div>
<p className={styles.bodyHint}>
Vorschau vollständige E-Mail in Outlook öffnen
</p>
</>
) : (
<p className={styles.bodyHint}>Kein Vorschautext verfügbar</p>
)}
{/* In Kontakt speichern */}
<div className={styles.saveSection}>
<button
type="button"
className={`${styles.saveBtn}${saveState === 'saved' ? ` ${styles.saveBtnSuccess}` : ''}${saveState === 'error' ? ` ${styles.saveBtnError}` : ''}`}
onClick={handleSave}
disabled={saveState === 'saving' || saveState === 'saved'}
>
{saveState === 'idle' && '+ In Kontakt speichern'}
{saveState === 'saving' && 'Speichern…'}
{saveState === 'saved' && '✓ Gespeichert'}
{saveState === 'error' && '✗ Fehler erneut versuchen'}
</button>
{saveState === 'saved' && (
<span className={styles.saveMsg}>
E-Mail als Aktivität gespeichert
</span>
)}
</div>
</div>
{/* Footer */}
<div className={styles.modalFooter}>
<span className={styles.modalFooterDate}>
{formatEmailDate(email.receivedDateTime)}
</span>
<a
href={email.webLink}
target="_blank"
rel="noopener noreferrer"
className={styles.outlookBtn}
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 2H2a1 1 0 00-1 1v10a1 1 0 001 1h12a1 1 0 001-1V8" />
<path d="M10 1h5v5" />
<path d="M6 10L15 1" />
</svg>
In Outlook öffnen
</a>
</div>
</div>
</div>
);
}
/* ── Hauptkomponente ──────────────────────────────────────────────────────── */
export function EmailsTab({ contactId }: Props) {
const { data: integrationsData } = useIntegrations();
const isConnected = integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false;
const isConnected =
(integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false);
const { data, isLoading, error } = useContactEmails(contactId);
const emails: M365Email[] = data?.data ?? [];
const [selectedEmail, setSelectedEmail] = useState<M365Email | null>(null);
if (!isConnected) {
return (
<div style={{ padding: '1.5rem 0', textAlign: 'center' }}>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.9375rem', marginBottom: '1rem' }}>
<p
style={{
color: 'var(--color-text-muted)',
fontSize: '0.9375rem',
marginBottom: '1rem',
}}
>
Verbinden Sie Microsoft 365, um E-Mails zu diesem Kontakt zu sehen.
</p>
<button
@ -55,68 +232,99 @@ export function EmailsTab({ contactId }: Props) {
}
if (isLoading) {
return <p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', padding: '1rem 0' }}>Laden</p>;
return (
<p
style={{
color: 'var(--color-text-muted)',
fontSize: '0.875rem',
padding: '1rem 0',
}}
>
Laden
</p>
);
}
if (error) {
return <p style={{ color: 'var(--color-error)', fontSize: '0.875rem', padding: '1rem 0' }}>E-Mails konnten nicht geladen werden.</p>;
return (
<p
style={{
color: 'var(--color-error)',
fontSize: '0.875rem',
padding: '1rem 0',
}}
>
E-Mails konnten nicht geladen werden.
</p>
);
}
if (emails.length === 0) {
return <p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', padding: '1rem 0' }}>Keine E-Mails gefunden.</p>;
return (
<p
style={{
color: 'var(--color-text-muted)',
fontSize: '0.875rem',
padding: '1rem 0',
}}
>
Keine E-Mails gefunden.
</p>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{emails.map((email) => (
<a
key={email.id}
href={email.webLink}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block',
padding: '0.75rem 1rem',
background: email.isRead ? 'var(--color-bg-card)' : 'var(--color-bg-subtle, var(--color-bg-card))',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
textDecoration: 'none',
borderLeft: email.isRead ? undefined : '3px solid var(--color-primary)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
<span style={{
fontSize: '0.875rem',
fontWeight: email.isRead ? 400 : 600,
color: 'var(--color-text)',
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{email.subject || '(kein Betreff)'}
</span>
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', flexShrink: 0 }}>
{formatEmailDate(email.receivedDateTime)}
</span>
</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginTop: '0.25rem' }}>
Von: {email.from?.emailAddress?.name ?? email.from?.emailAddress?.address ?? '—'}
</div>
{email.bodyPreview && (
<div style={{
fontSize: '0.8125rem',
color: 'var(--color-text-secondary)',
marginTop: '0.25rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{email.bodyPreview}
<>
<div className={styles.emailList}>
{emails.map((email) => (
<button
key={email.id}
type="button"
className={`${styles.emailItem}${!email.isRead ? ` ${styles.emailItemUnread}` : ''}`}
onClick={() => setSelectedEmail(email)}
>
{!email.isRead ? (
<span className={styles.unreadDot} aria-hidden="true" />
) : (
<span className={styles.readSpacer} />
)}
<div className={styles.emailMeta}>
<div className={styles.emailRow1}>
<span
className={`${styles.emailSubject}${!email.isRead ? ` ${styles.emailSubjectUnread}` : ''}`}
>
{email.subject ?? '(kein Betreff)'}
</span>
<span className={styles.emailTime}>
{formatEmailTime(email.receivedDateTime)}
</span>
</div>
<div className={styles.emailFrom}>
Von:{' '}
{email.from?.emailAddress?.name ??
email.from?.emailAddress?.address ??
'—'}
</div>
{email.bodyPreview && (
<div className={styles.emailPreview}>{email.bodyPreview}</div>
)}
</div>
)}
</a>
))}
</div>
{email.hasAttachments && (
<span className={styles.attachBadge} title="Anhänge vorhanden">
📎
</span>
)}
</button>
))}
</div>
{selectedEmail !== null && (
<EmailDetailModal
email={selectedEmail}
contactId={contactId}
onClose={() => setSelectedEmail(null)}
/>
)}
</>
);
}

View file

@ -22,6 +22,7 @@
.tabBar {
display: flex;
align-items: stretch;
gap: 0;
border-bottom: 2px solid var(--color-border);
margin-bottom: 1.75rem;
@ -55,6 +56,102 @@
min-height: 200px;
}
/* ── Profil-Bereich in der Tab-Leiste (rechts) ── */
.tabBarProfile {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0 0.75rem;
border-left: 1px solid var(--color-border);
background: var(--color-bg-card);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
/* leicht farblich abgesetzt */
box-shadow: inset 0 -2px 0 var(--color-border);
}
.tabBarThemeGroup {
display: flex;
gap: 1px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 2px;
}
.tabBarThemeBtn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 3px;
color: var(--color-text-muted);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
.tabBarThemeBtn:hover {
color: var(--color-text);
background: var(--color-bg-card);
}
.tabBarThemeBtnActive {
background: var(--color-primary) !important;
color: white !important;
}
.tabBarUser {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.1875rem 0.4375rem 0.1875rem 0.1875rem;
border-radius: var(--radius-sm);
border: 1px solid transparent;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
}
.tabBarUser:hover {
background: var(--color-bg);
border-color: var(--color-border);
}
.tabBarUserName {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text);
white-space: nowrap;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.tabBarLogout {
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;
flex-shrink: 0;
}
.tabBarLogout:hover {
background: var(--color-bg);
color: var(--color-text);
}
/* ── Home-Layout (Inhalt links + Agenda rechts) ── */
.homeLayout {

View file

@ -1,6 +1,8 @@
import { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { UserAvatar } from '../components/UserAvatar';
import { useTheme } from '../theme/ThemeContext';
import { WeatherWidget } from '../components/WeatherWidget';
import { AnalogClock } from '../components/AnalogClock';
import { DashboardEmailTab } from './DashboardEmailTab';
@ -688,11 +690,24 @@ function HomeTab({
// ── Main ──────────────────────────────────────────────────────────────────────
const THEME_OPTIONS_DASH = [
{ value: 'light' as const, icon: '☀' },
{ value: 'dark' as const, icon: '☾' },
{ value: 'system' as const, icon: '⚙' },
];
export function DashboardPage() {
const { user } = useAuth();
const { user, logout } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const { mode, setMode } = useTheme();
const [activeTab, setActiveTab] = useState<DashboardTab>('home');
const handleLogout = async () => {
await logout();
navigate('/login');
};
// Immer auf Home-Tab springen wenn Dashboard-NavLink geklickt wird
useEffect(() => {
setActiveTab('home');
@ -712,6 +727,69 @@ export function DashboardPage() {
{tab.label}
</button>
))}
{/* Profil-Bereich rechts in der Tab-Leiste */}
<div className={styles.tabBarProfile}>
{/* Theme-Schalter */}
<div className={styles.tabBarThemeGroup}>
{THEME_OPTIONS_DASH.map((opt) => (
<button
key={opt.value}
type="button"
className={`${styles.tabBarThemeBtn}${mode === opt.value ? ` ${styles.tabBarThemeBtnActive}` : ''}`}
onClick={() => setMode(opt.value)}
title={opt.value === 'light' ? 'Hell' : opt.value === 'dark' ? 'Dunkel' : 'System'}
>
{opt.icon}
</button>
))}
</div>
{/* Benutzer (→ Profil) */}
<div
className={styles.tabBarUser}
onClick={() => navigate('/profile')}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') navigate('/profile');
}}
title="Profil bearbeiten"
>
<UserAvatar
firstName={user?.firstName ?? ''}
lastName={user?.lastName ?? ''}
avatar={user?.avatar}
size={24}
/>
<span className={styles.tabBarUserName}>
{user?.firstName} {user?.lastName}
</span>
</div>
{/* Abmelden */}
<button
type="button"
className={styles.tabBarLogout}
onClick={handleLogout}
title="Abmelden"
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 3h3a1 1 0 011 1v8a1 1 0 01-1 1h-3" />
<path d="M7 11l3-3-3-3" />
<path d="M10 8H3" />
</svg>
</button>
</div>
</div>
{/* Tab-Inhalt */}