mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 22:46:39 +02:00
feat(dashboard): E-Mail Lesefenster + Kalender Umbau
E-Mail Tab: - Klick auf E-Mail öffnet Detail-Modal (Lesefenster wie Outlook) - Modal zeigt: Betreff, Absender, Datum, Anhang-Info, Body-Vorschau - CRM-Bereich: gefundener Kontakt mit "Im CRM öffnen" + "Als Aktivität" speichern; kein Kontakt → "Kontakt anlegen" navigiert zu /crm/contacts - "In Outlook öffnen" Link im Footer des Modals Kalender Tab: - WeekView: nur Arbeitstage Mo–Fr (5-Spalten-Grid statt 7) - Neue Ansicht "Agenda": 14-Tage-Listenansicht (eigener Toggle-Button) - Tages-Agenda nur bei Monat- und Wochenansicht sichtbar (nicht Agenda-View) - Home-Tab: Tages-Agenda des heutigen Tages als Widget rechts (nur sichtbar wenn M365 verbunden) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f2ed8d0a93
commit
fbf0b33a1f
6 changed files with 759 additions and 82 deletions
|
|
@ -277,11 +277,11 @@
|
|||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* ── Wochenansicht ── */
|
||||
/* ── Wochenansicht (Mo–Fr) ── */
|
||||
|
||||
.weekGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-columns: repeat(5, 1fr); /* nur Arbeitstage */
|
||||
}
|
||||
|
||||
.weekCol {
|
||||
|
|
@ -383,6 +383,130 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Agenda-Listenansicht (eigene View, 14 Tage) ── */
|
||||
|
||||
.agendaFullView {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agendaFullDay {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.agendaFullDay:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.agendaFullDay:hover {
|
||||
background: rgba(59, 130, 246, 0.03);
|
||||
}
|
||||
|
||||
.agendaFullDaySelected {
|
||||
background: rgba(59, 130, 246, 0.06) !important;
|
||||
}
|
||||
|
||||
.agendaFullDayHdr {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
padding: 0.875rem 0.5rem;
|
||||
gap: 0.125rem;
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.agendaFullDayHdrToday .agendaFullNum {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.agendaFullWeekday {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.agendaFullNum {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.agendaFullNumToday {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.agendaFullMonth {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.agendaFullEvents {
|
||||
flex: 1;
|
||||
padding: 0.625rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.agendaFullEmpty {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.agendaFullEvent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 2px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.agendaFullEvent:hover {
|
||||
background: rgba(59, 130, 246, 0.07);
|
||||
}
|
||||
|
||||
.agendaFullEventTime {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
min-width: 42px;
|
||||
}
|
||||
|
||||
.agendaFullEventSubj {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Tages-Agenda (rechte Spalte) ── */
|
||||
|
||||
.agenda {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useIntegrations, useOffice365CalendarRange } from '../crm/hooks';
|
|||
import type { M365CalendarEvent } from '../crm/types';
|
||||
import styles from './DashboardCalendarTab.module.css';
|
||||
|
||||
type ViewMode = 'month' | 'week';
|
||||
export type CalendarViewMode = 'month' | 'week' | 'agenda';
|
||||
|
||||
// ── Date Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -36,10 +36,10 @@ function getMonthCells(date: Date): Date[] {
|
|||
return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
|
||||
}
|
||||
|
||||
/** 7 Tage der Woche (Mo–So) */
|
||||
function getWeekDays(date: Date): Date[] {
|
||||
/** 5 Arbeitstage der Woche (Mo–Fr) */
|
||||
function getWorkWeekDays(date: Date): Date[] {
|
||||
const monday = startOfWeekMonday(date);
|
||||
return Array.from({ length: 7 }, (_, i) => addDays(monday, i));
|
||||
return Array.from({ length: 5 }, (_, i) => addDays(monday, i));
|
||||
}
|
||||
|
||||
function toISODate(date: Date): string {
|
||||
|
|
@ -70,9 +70,9 @@ function eventColor(id: string): string {
|
|||
return EVENT_COLORS[h];
|
||||
}
|
||||
|
||||
// ── Day Agenda (rechte Spalte) ─────────────────────────────────────────────────
|
||||
// ── Day Agenda (Tages-Detailansicht, auch für Home) ───────────────────────────
|
||||
|
||||
function DayAgenda({
|
||||
export function DayAgenda({
|
||||
day,
|
||||
events,
|
||||
}: {
|
||||
|
|
@ -137,6 +137,78 @@ function DayAgenda({
|
|||
);
|
||||
}
|
||||
|
||||
// ── Agenda-Listenansicht (eigene Ansicht, zeigt mehrere Tage) ─────────────────
|
||||
|
||||
function AgendaView({
|
||||
currentDate,
|
||||
events,
|
||||
selectedDay,
|
||||
onDayClick,
|
||||
}: {
|
||||
currentDate: Date;
|
||||
events: M365CalendarEvent[];
|
||||
selectedDay: Date;
|
||||
onDayClick: (d: Date) => void;
|
||||
}) {
|
||||
const today = new Date();
|
||||
// 14 Tage ab currentDate
|
||||
const days = Array.from({ length: 14 }, (_, i) => addDays(currentDate, i));
|
||||
|
||||
return (
|
||||
<div className={styles.agendaFullView}>
|
||||
{days.map((day) => {
|
||||
const dayEvents = getEventsForDay(events, day);
|
||||
const isToday = isSameDay(day, today);
|
||||
const isSelected = isSameDay(day, selectedDay);
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className={`${styles.agendaFullDay} ${isSelected ? styles.agendaFullDaySelected : ''}`}
|
||||
onClick={() => onDayClick(day)}
|
||||
>
|
||||
<div className={`${styles.agendaFullDayHdr} ${isToday ? styles.agendaFullDayHdrToday : ''}`}>
|
||||
<span className={styles.agendaFullWeekday}>
|
||||
{day.toLocaleDateString('de-DE', { weekday: 'short' })}
|
||||
</span>
|
||||
<span className={`${styles.agendaFullNum} ${isToday ? styles.agendaFullNumToday : ''}`}>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
<span className={styles.agendaFullMonth}>
|
||||
{day.toLocaleDateString('de-DE', { month: 'short' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.agendaFullEvents}>
|
||||
{dayEvents.length === 0 ? (
|
||||
<span className={styles.agendaFullEmpty}>Kein Termin</span>
|
||||
) : (
|
||||
dayEvents.map((e) => (
|
||||
<a
|
||||
key={e.id}
|
||||
href={e.webLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.agendaFullEvent}
|
||||
style={{ borderLeftColor: eventColor(e.id) }}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
>
|
||||
<span className={styles.agendaFullEventTime}>
|
||||
{formatTime(e.start.dateTime)}
|
||||
</span>
|
||||
<span className={styles.agendaFullEventSubj}>{e.subject}</span>
|
||||
{e.isOnlineMeeting && (
|
||||
<span className={styles.onlineBadge}>Online</span>
|
||||
)}
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Monatsansicht ─────────────────────────────────────────────────────────────
|
||||
|
||||
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
|
|
@ -211,7 +283,7 @@ function MonthView({
|
|||
);
|
||||
}
|
||||
|
||||
// ── Wochenansicht ─────────────────────────────────────────────────────────────
|
||||
// ── Wochenansicht (Mo–Fr) ─────────────────────────────────────────────────────
|
||||
|
||||
function WeekView({
|
||||
currentDate,
|
||||
|
|
@ -225,7 +297,7 @@ function WeekView({
|
|||
onDayClick: (d: Date) => void;
|
||||
}) {
|
||||
const today = new Date();
|
||||
const weekDays = getWeekDays(currentDate);
|
||||
const weekDays = getWorkWeekDays(currentDate); // nur Mo–Fr
|
||||
|
||||
return (
|
||||
<div className={styles.weekGrid}>
|
||||
|
|
@ -287,7 +359,7 @@ export function DashboardCalendarTab() {
|
|||
(i) => i.provider === 'MICROSOFT_365' && i.connected,
|
||||
) ?? false;
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('month');
|
||||
const [viewMode, setViewMode] = useState<CalendarViewMode>('month');
|
||||
const [currentDate, setCurrentDate] = useState(() => new Date());
|
||||
const [selectedDay, setSelectedDay] = useState(() => new Date());
|
||||
|
||||
|
|
@ -297,12 +369,16 @@ export function DashboardCalendarTab() {
|
|||
? startOfWeekMonday(
|
||||
new Date(currentDate.getFullYear(), currentDate.getMonth(), 1),
|
||||
)
|
||||
: startOfWeekMonday(currentDate);
|
||||
: viewMode === 'week'
|
||||
? startOfWeekMonday(currentDate)
|
||||
: currentDate; // agenda: ab currentDate
|
||||
|
||||
const rangeEnd =
|
||||
viewMode === 'month'
|
||||
? addDays(rangeStart, 42) // 6 Wochen
|
||||
: addDays(startOfWeekMonday(currentDate), 7);
|
||||
? addDays(rangeStart, 42)
|
||||
: viewMode === 'week'
|
||||
? addDays(startOfWeekMonday(currentDate), 5) // Mo–Fr
|
||||
: addDays(currentDate, 14); // 14 Tage Agenda
|
||||
|
||||
const { data: eventsData, isLoading, error } = useOffice365CalendarRange(
|
||||
toISODate(rangeStart),
|
||||
|
|
@ -314,8 +390,10 @@ export function DashboardCalendarTab() {
|
|||
const d = new Date(currentDate);
|
||||
if (viewMode === 'month') {
|
||||
d.setMonth(d.getMonth() + delta);
|
||||
} else {
|
||||
} else if (viewMode === 'week') {
|
||||
d.setDate(d.getDate() + delta * 7);
|
||||
} else {
|
||||
d.setDate(d.getDate() + delta * 14);
|
||||
}
|
||||
setCurrentDate(d);
|
||||
};
|
||||
|
|
@ -327,18 +405,26 @@ export function DashboardCalendarTab() {
|
|||
};
|
||||
|
||||
// Anzeigebezeichnung
|
||||
const rangeLabel =
|
||||
viewMode === 'month'
|
||||
? currentDate.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
|
||||
: (() => {
|
||||
const days = getWeekDays(currentDate);
|
||||
const f = days[0];
|
||||
const l = days[6];
|
||||
const sm = f.getMonth() === l.getMonth();
|
||||
return sm
|
||||
? `${f.getDate()}. – ${l.getDate()}. ${l.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })}`
|
||||
: `${f.getDate()}. ${f.toLocaleDateString('de-DE', { month: 'short' })} – ${l.getDate()}. ${l.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' })}`;
|
||||
})();
|
||||
const rangeLabel = (() => {
|
||||
if (viewMode === 'month') {
|
||||
return currentDate.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
if (viewMode === 'week') {
|
||||
const days = getWorkWeekDays(currentDate);
|
||||
const f = days[0];
|
||||
const l = days[4];
|
||||
const sm = f.getMonth() === l.getMonth();
|
||||
return sm
|
||||
? `${f.getDate()}. – ${l.getDate()}. ${l.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })}`
|
||||
: `${f.getDate()}. ${f.toLocaleDateString('de-DE', { month: 'short' })} – ${l.getDate()}. ${l.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' })}`;
|
||||
}
|
||||
// agenda
|
||||
const end = addDays(currentDate, 13);
|
||||
const sm = currentDate.getMonth() === end.getMonth();
|
||||
return sm
|
||||
? `${currentDate.getDate()}. – ${end.getDate()}. ${end.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })}`
|
||||
: `${currentDate.getDate()}. ${currentDate.toLocaleDateString('de-DE', { month: 'short' })} – ${end.getDate()}. ${end.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' })}`;
|
||||
})();
|
||||
|
||||
if (intLoading) {
|
||||
return <p className={styles.status}>Verbindung wird geprüft…</p>;
|
||||
|
|
@ -403,6 +489,13 @@ export function DashboardCalendarTab() {
|
|||
>
|
||||
Woche
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.viewBtn} ${viewMode === 'agenda' ? styles.viewBtnActive : ''}`}
|
||||
onClick={() => setViewMode('agenda')}
|
||||
>
|
||||
Agenda
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -416,18 +509,19 @@ export function DashboardCalendarTab() {
|
|||
</p>
|
||||
)}
|
||||
|
||||
{/* Hauptbereich: Kalender + Agenda */}
|
||||
{/* Hauptbereich */}
|
||||
{!isLoading && (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.calendarPanel}>
|
||||
{viewMode === 'month' ? (
|
||||
{viewMode === 'month' && (
|
||||
<MonthView
|
||||
currentDate={currentDate}
|
||||
selectedDay={selectedDay}
|
||||
events={events}
|
||||
onDayClick={setSelectedDay}
|
||||
/>
|
||||
) : (
|
||||
)}
|
||||
{viewMode === 'week' && (
|
||||
<WeekView
|
||||
currentDate={currentDate}
|
||||
selectedDay={selectedDay}
|
||||
|
|
@ -435,9 +529,20 @@ export function DashboardCalendarTab() {
|
|||
onDayClick={setSelectedDay}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'agenda' && (
|
||||
<AgendaView
|
||||
currentDate={currentDate}
|
||||
events={events}
|
||||
selectedDay={selectedDay}
|
||||
onDayClick={setSelectedDay}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DayAgenda day={selectedDay} events={events} />
|
||||
{/* Tages-Agenda nur bei Monat- und Wochenansicht */}
|
||||
{(viewMode === 'month' || viewMode === 'week') && (
|
||||
<DayAgenda day={selectedDay} events={events} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -212,6 +212,7 @@
|
|||
border-radius: var(--radius-sm);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.emailCard:hover {
|
||||
|
|
@ -223,12 +224,10 @@
|
|||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.emailLink {
|
||||
.emailCardInner {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
padding-right: 6rem; /* Platz für den "Aktivität"-Button */
|
||||
}
|
||||
|
||||
.emailHeader {
|
||||
|
|
@ -307,36 +306,261 @@
|
|||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── Aktivität-Button (absolut positioniert innerhalb der Karte) ── */
|
||||
/* ── Detail-Modal (E-Mail Lesefenster) ── */
|
||||
|
||||
.saveActivityBtn {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
padding: 0.3rem 0.625rem;
|
||||
.detailModal {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detailHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.5rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detailSubject {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
margin-top: -0.125rem;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.detailMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: var(--color-bg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detailMetaRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detailMetaLabel {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
min-width: 48px;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.detailMetaValue {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.detailSenderEmail {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.detailBody {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
min-height: 100px;
|
||||
max-height: 280px;
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.detailBodyEmpty {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── CRM-Bereich im Detail-Modal ── */
|
||||
|
||||
.detailCrm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-primary);
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detailCrmTitle {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detailCrmLoading {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.detailCrmFound {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detailCrmInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.detailCrmAvatar {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.detailCrmName {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.detailCrmCompany {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.detailCrmActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detailCrmOpenBtn {
|
||||
padding: 0.375rem 0.875rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.emailCard:hover .saveActivityBtn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.saveActivityBtn:hover {
|
||||
.detailCrmOpenBtn:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.detailCrmMissing {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detailCrmMissingText {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.detailCrmCreateBtn {
|
||||
padding: 0.375rem 0.875rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detailCrmCreateBtn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Detail-Footer ── */
|
||||
|
||||
.detailFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-bg-card);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.outlookBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.4375rem 1rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.outlookBtn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Aktivität-Modal ── */
|
||||
|
||||
.modalOverlay {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
useIntegrations,
|
||||
|
|
@ -67,27 +68,28 @@ function formatEmailDate(iso: string): string {
|
|||
});
|
||||
}
|
||||
|
||||
// ── EmailCard ─────────────────────────────────────────────────────────────────
|
||||
// ── EmailCard (Klick öffnet Detail-Modal) ────────────────────────────────────
|
||||
|
||||
interface EmailCardProps {
|
||||
email: M365Email;
|
||||
onSaveActivity: (email: M365Email, contact: CrmContactLookup) => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function EmailCard({ email, onSaveActivity }: EmailCardProps) {
|
||||
function EmailCard({ email, onClick }: EmailCardProps) {
|
||||
const senderEmail = email.from?.emailAddress?.address ?? null;
|
||||
const { data: contactData } = useContactByEmail(senderEmail);
|
||||
const contact = contactData?.data ?? null;
|
||||
const displayName = email.from?.emailAddress?.name || senderEmail || '—';
|
||||
|
||||
return (
|
||||
<div className={`${styles.emailCard} ${!email.isRead ? styles.emailCardUnread : ''}`}>
|
||||
<a
|
||||
href={email.webLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.emailLink}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`${styles.emailCard} ${!email.isRead ? styles.emailCardUnread : ''}`}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onClick()}
|
||||
>
|
||||
<div className={styles.emailCardInner}>
|
||||
<div className={styles.emailHeader}>
|
||||
<span className={styles.emailSubject}>
|
||||
{!email.isRead && <span className={styles.unreadDot} aria-hidden="true" />}
|
||||
|
|
@ -114,17 +116,168 @@ function EmailCard({ email, onSaveActivity }: EmailCardProps) {
|
|||
{email.bodyPreview && (
|
||||
<p className={styles.emailPreview}>{email.bodyPreview}</p>
|
||||
)}
|
||||
</a>
|
||||
{contact && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.saveActivityBtn}
|
||||
title={`Als Aktivität für ${[contact.firstName, contact.lastName].filter(Boolean).join(' ')} speichern`}
|
||||
onClick={() => onSaveActivity(email, contact)}
|
||||
>
|
||||
+ Aktivität
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── EmailDetailModal ──────────────────────────────────────────────────────────
|
||||
|
||||
interface EmailDetailModalProps {
|
||||
email: M365Email;
|
||||
onClose: () => void;
|
||||
onSaveActivity: (email: M365Email, contact: CrmContactLookup) => void;
|
||||
}
|
||||
|
||||
function EmailDetailModal({ email, onClose, onSaveActivity }: EmailDetailModalProps) {
|
||||
const navigate = useNavigate();
|
||||
const senderEmail = email.from?.emailAddress?.address ?? null;
|
||||
const senderName = email.from?.emailAddress?.name ?? '';
|
||||
|
||||
const { data: contactData, isLoading: contactLoading } = useContactByEmail(senderEmail);
|
||||
const contact = contactData?.data ?? null;
|
||||
|
||||
const contactName =
|
||||
contact
|
||||
? [contact.firstName, contact.lastName].filter(Boolean).join(' ') ||
|
||||
contact.email ||
|
||||
'—'
|
||||
: null;
|
||||
|
||||
const handleOpenContact = () => {
|
||||
onClose();
|
||||
navigate(`/crm/contacts/${contact!.id}`);
|
||||
};
|
||||
|
||||
const handleCreateContact = () => {
|
||||
onClose();
|
||||
navigate('/crm/contacts');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.modalOverlay} onClick={onClose}>
|
||||
<div className={styles.detailModal} onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
{/* ── Kopfzeile ── */}
|
||||
<div className={styles.detailHeader}>
|
||||
<h2 className={styles.detailSubject}>
|
||||
{email.subject || '(Kein Betreff)'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeBtn}
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Meta ── */}
|
||||
<div className={styles.detailMeta}>
|
||||
<div className={styles.detailMetaRow}>
|
||||
<span className={styles.detailMetaLabel}>Von</span>
|
||||
<span className={styles.detailMetaValue}>
|
||||
{senderName || senderEmail || '—'}
|
||||
{senderName && senderEmail && (
|
||||
<span className={styles.detailSenderEmail}> <{senderEmail}></span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.detailMetaRow}>
|
||||
<span className={styles.detailMetaLabel}>Datum</span>
|
||||
<span className={styles.detailMetaValue}>
|
||||
{new Date(email.receivedDateTime).toLocaleString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{email.hasAttachments && (
|
||||
<div className={styles.detailMetaRow}>
|
||||
<span className={styles.detailMetaLabel}>Anhang</span>
|
||||
<span className={styles.detailMetaValue}>📎 Vorhanden</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Body-Vorschau ── */}
|
||||
<div className={styles.detailBody}>
|
||||
{email.bodyPreview || (
|
||||
<em className={styles.detailBodyEmpty}>Kein Vorschautext verfügbar.</em>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── CRM-Bereich ── */}
|
||||
<div className={styles.detailCrm}>
|
||||
<span className={styles.detailCrmTitle}>CRM</span>
|
||||
|
||||
{contactLoading ? (
|
||||
<span className={styles.detailCrmLoading}>Wird geprüft…</span>
|
||||
) : contact ? (
|
||||
<div className={styles.detailCrmFound}>
|
||||
<div className={styles.detailCrmInfo}>
|
||||
<span className={styles.detailCrmAvatar}>👤</span>
|
||||
<div>
|
||||
<span className={styles.detailCrmName}>{contactName}</span>
|
||||
{contact.companyName && (
|
||||
<span className={styles.detailCrmCompany}> · {contact.companyName}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailCrmActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailCrmOpenBtn}
|
||||
onClick={handleOpenContact}
|
||||
>
|
||||
Im CRM öffnen →
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.saveBtn}
|
||||
onClick={() => { onClose(); onSaveActivity(email, contact); }}
|
||||
>
|
||||
Als Aktivität speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.detailCrmMissing}>
|
||||
<span className={styles.detailCrmMissingText}>
|
||||
{senderEmail
|
||||
? `Kein CRM-Kontakt für ${senderEmail}`
|
||||
: 'Kein Absender bekannt'}
|
||||
</span>
|
||||
{senderEmail && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailCrmCreateBtn}
|
||||
onClick={handleCreateContact}
|
||||
>
|
||||
+ Kontakt anlegen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className={styles.detailFooter}>
|
||||
<a
|
||||
href={email.webLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.outlookBtn}
|
||||
>
|
||||
↗ In Outlook öffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -242,6 +395,7 @@ export function DashboardEmailTab() {
|
|||
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const [days, setDays] = useState<number>(7);
|
||||
const [detailEmail, setDetailEmail] = useState<M365Email | null>(null);
|
||||
const [activityTarget, setActivityTarget] = useState<ActivityTarget | null>(null);
|
||||
const [lastSaved, setLastSaved] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -252,7 +406,6 @@ export function DashboardEmailTab() {
|
|||
|
||||
// Standardmäßig Posteingang — Graph API akzeptiert Well-Known-Namen direkt als ID,
|
||||
// sodass E-Mails sofort geladen werden, bevor die Ordnerliste verfügbar ist.
|
||||
// Sobald Ordner geladen sind, wird die echte ID aus der Liste verwendet.
|
||||
const inboxFolder = sortedFolders.find(isInboxFolder);
|
||||
const activeFolderId = selectedFolderId ?? inboxFolder?.id ?? 'inbox';
|
||||
|
||||
|
|
@ -370,15 +523,28 @@ export function DashboardEmailTab() {
|
|||
<EmailCard
|
||||
key={email.id}
|
||||
email={email}
|
||||
onSaveActivity={(e, contact) => {
|
||||
onClick={() => {
|
||||
setLastSaved(null);
|
||||
setActivityTarget({ email: e, contact });
|
||||
setDetailEmail(email);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Detail-Modal */}
|
||||
{detailEmail && (
|
||||
<EmailDetailModal
|
||||
email={detailEmail}
|
||||
onClose={() => setDetailEmail(null)}
|
||||
onSaveActivity={(e, contact) => {
|
||||
setDetailEmail(null);
|
||||
setLastSaved(null);
|
||||
setActivityTarget({ email: e, contact });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Aktivität-Modal */}
|
||||
{activityTarget && (
|
||||
<ActivityModal
|
||||
|
|
|
|||
|
|
@ -55,6 +55,27 @@
|
|||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* ── Home-Layout (Inhalt links + Agenda rechts) ── */
|
||||
|
||||
.homeLayout {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.homeMain {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.homeSidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Platzhalter für bestehenden Home-Inhalt ── */
|
||||
|
||||
.placeholder {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import { useAuth } from '../auth/AuthContext';
|
|||
import { WeatherWidget } from '../components/WeatherWidget';
|
||||
import { EventCountdownTiles } from '../components/EventCountdownTiles';
|
||||
import { DashboardEmailTab } from './DashboardEmailTab';
|
||||
import { DashboardCalendarTab } from './DashboardCalendarTab';
|
||||
import { DashboardCalendarTab, DayAgenda } from './DashboardCalendarTab';
|
||||
import { useIntegrations, useOffice365CalendarRange } from '../crm/hooks';
|
||||
import type { M365CalendarEvent } from '../crm/types';
|
||||
import styles from './DashboardPage.module.css';
|
||||
|
||||
type DashboardTab = 'home' | 'emails' | 'calendar' | 'tasks' | 'contacts';
|
||||
|
|
@ -16,6 +18,36 @@ const TABS: { id: DashboardTab; label: string }[] = [
|
|||
{ id: 'contacts', label: 'Kontakte' },
|
||||
];
|
||||
|
||||
// ── Tages-Agenda Widget für Home-Tab ─────────────────────────────────────────
|
||||
|
||||
function HomeDayAgendaWidget() {
|
||||
const today = new Date();
|
||||
const todayISO = today.toISOString().slice(0, 10);
|
||||
const tomorrowISO = new Date(today.getTime() + 86_400_000).toISOString().slice(0, 10);
|
||||
|
||||
const { data: integrationsData } = useIntegrations();
|
||||
const isConnected =
|
||||
integrationsData?.data?.some(
|
||||
(i) => i.provider === 'MICROSOFT_365' && i.connected,
|
||||
) ?? false;
|
||||
|
||||
const { data: eventsData, isLoading } = useOffice365CalendarRange(todayISO, tomorrowISO);
|
||||
const events: M365CalendarEvent[] = eventsData?.data ?? [];
|
||||
|
||||
if (!isConnected) return null;
|
||||
if (isLoading) return (
|
||||
<div className={styles.homeSidebar}>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)' }}>Kalender wird geladen…</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.homeSidebar}>
|
||||
<DayAgenda day={today} events={events} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab-Inhalte ───────────────────────────────────────────────────────────────
|
||||
|
||||
function HomeTab({ firstName, lastName, city, role }: {
|
||||
|
|
@ -32,14 +64,19 @@ function HomeTab({ firstName, lastName, city, role }: {
|
|||
</h1>
|
||||
<WeatherWidget city={city ?? undefined} />
|
||||
</div>
|
||||
<EventCountdownTiles />
|
||||
<div className={styles.placeholder}>
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>
|
||||
INSIGHT Platform - Sprint 1 Alpha
|
||||
</p>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', marginTop: '0.5rem' }}>
|
||||
Rolle: {role}
|
||||
</p>
|
||||
<div className={styles.homeLayout}>
|
||||
<div className={styles.homeMain}>
|
||||
<EventCountdownTiles />
|
||||
<div className={styles.placeholder}>
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>
|
||||
INSIGHT Platform - Sprint 1 Alpha
|
||||
</p>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', marginTop: '0.5rem' }}>
|
||||
Rolle: {role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<HomeDayAgendaWidget />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue