mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 01:36:39 +02:00
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:
parent
17a81c97ef
commit
2af54246c8
6 changed files with 1050 additions and 90 deletions
|
|
@ -191,22 +191,99 @@
|
||||||
font-weight: 500;
|
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 {
|
.timeline {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.timelineItem {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.875rem;
|
||||||
padding: 0.75rem 0;
|
padding: 0.625rem 0;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timelineItem:last-child {
|
.timelineItem:last-child {
|
||||||
border-bottom: none;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timelineIcon {
|
.timelineIcon {
|
||||||
|
|
@ -214,35 +291,81 @@
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg-card);
|
||||||
border: 1px solid var(--color-border);
|
border: 2px solid var(--color-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.timelineContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding-top: 0.1875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timelineSubject {
|
.timelineSubject {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timelineMeta {
|
.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;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin-top: 0.125rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timelineDesc {
|
.timelineDesc {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
margin-top: 0.375rem;
|
margin-top: 0.3125rem;
|
||||||
white-space: pre-wrap;
|
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 ---- */
|
/* ---- Notes ---- */
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,11 @@ export function ContactDetailPage() {
|
||||||
const [isDeleteOpen, setDeleteOpen] = useState(false);
|
const [isDeleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [m365Tab, setM365Tab] = useState<M365Tab>('emails');
|
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 (isLoading) return <p>Laden…</p>;
|
||||||
if (error || !data)
|
if (error || !data)
|
||||||
return (
|
return (
|
||||||
|
|
@ -128,9 +133,31 @@ export function ContactDetailPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const contact = data.data;
|
const contact = data.data;
|
||||||
const activities: Activity[] = contact.activities ?? [];
|
const allActivities: Activity[] = contact.activities ?? [];
|
||||||
const deals = dealsData?.data ?? [];
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Back link */}
|
{/* Back link */}
|
||||||
|
|
@ -495,6 +522,7 @@ export function ContactDetailPage() {
|
||||||
|
|
||||||
{/* ── Aktivitäten (full width below) ── */}
|
{/* ── Aktivitäten (full width below) ── */}
|
||||||
<div className={styles.card} style={{ marginTop: '1.5rem' }}>
|
<div className={styles.card} style={{ marginTop: '1.5rem' }}>
|
||||||
|
{/* Karten-Header */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -505,6 +533,18 @@ export function ContactDetailPage() {
|
||||||
>
|
>
|
||||||
<h2 className={styles.cardTitle} style={{ margin: 0 }}>
|
<h2 className={styles.cardTitle} style={{ margin: 0 }}>
|
||||||
Aktivitäten
|
Aktivitäten
|
||||||
|
{allActivities.length > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '0.5rem',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 400,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
({allActivities.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActivityOpen(true)}
|
onClick={() => setActivityOpen(true)}
|
||||||
|
|
@ -523,31 +563,113 @@ export function ContactDetailPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ? (
|
{activities.length === 0 ? (
|
||||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>
|
<p className={styles.timelineEmpty}>
|
||||||
Keine Aktivitäten vorhanden
|
{isFilterActive
|
||||||
|
? 'Keine Aktivitäten für den gewählten Filter'
|
||||||
|
: 'Keine Aktivitäten vorhanden'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.timeline}>
|
<div className={styles.timeline}>
|
||||||
{activities.map((act) => (
|
{activities.map((act) => {
|
||||||
<div key={act.id} className={styles.timelineItem}>
|
const iconClass = {
|
||||||
<div className={styles.timelineIcon}>
|
NOTE: styles.timelineIconNote,
|
||||||
{activityIcon(act.type)}
|
CALL: styles.timelineIconCall,
|
||||||
</div>
|
EMAIL: styles.timelineIconEmail,
|
||||||
<div className={styles.timelineContent}>
|
MEETING: styles.timelineIconMeeting,
|
||||||
<div className={styles.timelineSubject}>{act.subject}</div>
|
TASK: styles.timelineIconTask,
|
||||||
<div className={styles.timelineMeta}>
|
FOLLOWUP: styles.timelineIconFollowup,
|
||||||
{ACTIVITY_TYPE_LABELS[act.type]} ·{' '}
|
}[act.type] ?? '';
|
||||||
{formatDate(act.createdAt)}
|
|
||||||
|
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>
|
</div>
|
||||||
{act.description && (
|
<div className={styles.timelineContent}>
|
||||||
<div className={styles.timelineDesc}>
|
<div className={styles.timelineSubject}>{act.subject}</div>
|
||||||
{act.description}
|
<div className={styles.timelineMeta}>
|
||||||
|
<span
|
||||||
|
className={`${styles.timelineTypeBadge} ${badgeClass}`}
|
||||||
|
>
|
||||||
|
{ACTIVITY_TYPE_LABELS[act.type]}
|
||||||
|
</span>
|
||||||
|
<span className={styles.timelineDate}>
|
||||||
|
{formatDate(act.createdAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{act.description && (
|
||||||
|
<div className={styles.timelineDesc}>
|
||||||
|
{act.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
332
packages/frontend/src/crm/contacts/EmailsTab.module.css
Normal file
332
packages/frontend/src/crm/contacts/EmailsTab.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useContactEmails, useIntegrations } from '../hooks';
|
import { useState } from 'react';
|
||||||
|
import { useContactEmails, useIntegrations, useCreateActivity } from '../hooks';
|
||||||
import { integrationsApi } from '../api';
|
import { integrationsApi } from '../api';
|
||||||
import type { M365Email } from '../types';
|
import type { M365Email, M365EmailAddress } from '../types';
|
||||||
|
import styles from './EmailsTab.module.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
contactId: string;
|
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) {
|
export function EmailsTab({ contactId }: Props) {
|
||||||
const { data: integrationsData } = useIntegrations();
|
const { data: integrationsData } = useIntegrations();
|
||||||
const isConnected = integrationsData?.data?.some(
|
const isConnected =
|
||||||
(i) => i.provider === 'MICROSOFT_365' && i.connected,
|
(integrationsData?.data?.some(
|
||||||
) ?? false;
|
(i) => i.provider === 'MICROSOFT_365' && i.connected,
|
||||||
|
) ?? false);
|
||||||
|
|
||||||
const { data, isLoading, error } = useContactEmails(contactId);
|
const { data, isLoading, error } = useContactEmails(contactId);
|
||||||
const emails: M365Email[] = data?.data ?? [];
|
const emails: M365Email[] = data?.data ?? [];
|
||||||
|
const [selectedEmail, setSelectedEmail] = useState<M365Email | null>(null);
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem 0', textAlign: 'center' }}>
|
<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.
|
Verbinden Sie Microsoft 365, um E-Mails zu diesem Kontakt zu sehen.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
|
@ -55,68 +232,99 @@ export function EmailsTab({ contactId }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
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) {
|
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) {
|
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 (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
<>
|
||||||
{emails.map((email) => (
|
<div className={styles.emailList}>
|
||||||
<a
|
{emails.map((email) => (
|
||||||
key={email.id}
|
<button
|
||||||
href={email.webLink}
|
key={email.id}
|
||||||
target="_blank"
|
type="button"
|
||||||
rel="noopener noreferrer"
|
className={`${styles.emailItem}${!email.isRead ? ` ${styles.emailItemUnread}` : ''}`}
|
||||||
style={{
|
onClick={() => setSelectedEmail(email)}
|
||||||
display: 'block',
|
>
|
||||||
padding: '0.75rem 1rem',
|
{!email.isRead ? (
|
||||||
background: email.isRead ? 'var(--color-bg-card)' : 'var(--color-bg-subtle, var(--color-bg-card))',
|
<span className={styles.unreadDot} aria-hidden="true" />
|
||||||
border: '1px solid var(--color-border)',
|
) : (
|
||||||
borderRadius: 'var(--radius-sm)',
|
<span className={styles.readSpacer} />
|
||||||
textDecoration: 'none',
|
)}
|
||||||
borderLeft: email.isRead ? undefined : '3px solid var(--color-primary)',
|
<div className={styles.emailMeta}>
|
||||||
}}
|
<div className={styles.emailRow1}>
|
||||||
>
|
<span
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
|
className={`${styles.emailSubject}${!email.isRead ? ` ${styles.emailSubjectUnread}` : ''}`}
|
||||||
<span style={{
|
>
|
||||||
fontSize: '0.875rem',
|
{email.subject ?? '(kein Betreff)'}
|
||||||
fontWeight: email.isRead ? 400 : 600,
|
</span>
|
||||||
color: 'var(--color-text)',
|
<span className={styles.emailTime}>
|
||||||
flex: 1,
|
{formatEmailTime(email.receivedDateTime)}
|
||||||
overflow: 'hidden',
|
</span>
|
||||||
textOverflow: 'ellipsis',
|
</div>
|
||||||
whiteSpace: 'nowrap',
|
<div className={styles.emailFrom}>
|
||||||
}}>
|
Von:{' '}
|
||||||
{email.subject || '(kein Betreff)'}
|
{email.from?.emailAddress?.name ??
|
||||||
</span>
|
email.from?.emailAddress?.address ??
|
||||||
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', flexShrink: 0 }}>
|
'—'}
|
||||||
{formatEmailDate(email.receivedDateTime)}
|
</div>
|
||||||
</span>
|
{email.bodyPreview && (
|
||||||
</div>
|
<div className={styles.emailPreview}>{email.bodyPreview}</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>
|
</div>
|
||||||
)}
|
{email.hasAttachments && (
|
||||||
</a>
|
<span className={styles.attachBadge} title="Anhänge vorhanden">
|
||||||
))}
|
📎
|
||||||
</div>
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedEmail !== null && (
|
||||||
|
<EmailDetailModal
|
||||||
|
email={selectedEmail}
|
||||||
|
contactId={contactId}
|
||||||
|
onClose={() => setSelectedEmail(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
|
|
||||||
.tabBar {
|
.tabBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
border-bottom: 2px solid var(--color-border);
|
border-bottom: 2px solid var(--color-border);
|
||||||
margin-bottom: 1.75rem;
|
margin-bottom: 1.75rem;
|
||||||
|
|
@ -55,6 +56,102 @@
|
||||||
min-height: 200px;
|
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) ── */
|
/* ── Home-Layout (Inhalt links + Agenda rechts) ── */
|
||||||
|
|
||||||
.homeLayout {
|
.homeLayout {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { UserAvatar } from '../components/UserAvatar';
|
||||||
|
import { useTheme } from '../theme/ThemeContext';
|
||||||
import { WeatherWidget } from '../components/WeatherWidget';
|
import { WeatherWidget } from '../components/WeatherWidget';
|
||||||
import { AnalogClock } from '../components/AnalogClock';
|
import { AnalogClock } from '../components/AnalogClock';
|
||||||
import { DashboardEmailTab } from './DashboardEmailTab';
|
import { DashboardEmailTab } from './DashboardEmailTab';
|
||||||
|
|
@ -688,11 +690,24 @@ function HomeTab({
|
||||||
|
|
||||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const THEME_OPTIONS_DASH = [
|
||||||
|
{ value: 'light' as const, icon: '☀' },
|
||||||
|
{ value: 'dark' as const, icon: '☾' },
|
||||||
|
{ value: 'system' as const, icon: '⚙' },
|
||||||
|
];
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { user } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { mode, setMode } = useTheme();
|
||||||
const [activeTab, setActiveTab] = useState<DashboardTab>('home');
|
const [activeTab, setActiveTab] = useState<DashboardTab>('home');
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
// Immer auf Home-Tab springen wenn Dashboard-NavLink geklickt wird
|
// Immer auf Home-Tab springen wenn Dashboard-NavLink geklickt wird
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveTab('home');
|
setActiveTab('home');
|
||||||
|
|
@ -712,6 +727,69 @@ export function DashboardPage() {
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Tab-Inhalt */}
|
{/* Tab-Inhalt */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue