feat: Dashboard Home-Tab mit Analoguhr, 3-Tage-Prognose, Spruch des Tages + kompakte Widgets

- Analoge SVG-Uhr (AnalogClock.tsx) aktualisiert jede Sekunde via setInterval
- useWeather: 3-Tage-Prognose via Open-Meteo daily-Parameter (weather_code, tempMax, tempMin)
- Dashboard Home-Tab: 3-Spalten-Layout (Uhr+Wetter links | Aufgaben+Mails mitte | Messe+Agenda rechts)
- Spruch des Tages rechts im Header (deterministisch nach Tagesdatum, 35 dt. Zitate)
- WeatherWidget aus dem Header in die linke Spalte verschoben
- Kompaktes Aufgaben-Widget: Top 8 offene Aufgaben (CRM + O365), direkt erledigbar
- Kompaktes E-Mail-Widget: Posteingang der letzten 3 Tage, direkter Öffnen-Link
- „Alle →" Buttons schalten auf den jeweiligen Tab um

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-13 13:30:45 +01:00
parent 2348602fb0
commit ceea82a2ac
5 changed files with 1000 additions and 29 deletions

View file

@ -0,0 +1,80 @@
/* ============================================================
AnalogClock SVG-Analoguhr
============================================================ */
.root {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3125rem;
}
.svg {
width: 148px;
height: 148px;
overflow: visible;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.08));
}
/* Ziffernblatt */
.face {
fill: var(--color-bg-card);
}
.border {
stroke: var(--color-border);
stroke-width: 1.5;
}
/* Stundenmarkierungen */
.tickMajor {
stroke: var(--color-text);
stroke-width: 1.75;
stroke-linecap: round;
}
.tickMinor {
stroke: var(--color-text-muted);
stroke-width: 0.85;
stroke-linecap: round;
}
/* Zeiger */
.hourHand {
stroke: var(--color-text);
stroke-width: 3.75;
stroke-linecap: round;
}
.minuteHand {
stroke: var(--color-text);
stroke-width: 2.5;
stroke-linecap: round;
}
.secondHand {
stroke: #ef4444;
stroke-width: 1.5;
stroke-linecap: round;
}
/* Mittelpunkt */
.centerDot {
fill: #ef4444;
}
/* Digitale Zeit darunter */
.timeDigital {
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 0.06em;
font-variant-numeric: tabular-nums;
color: var(--color-text);
}
.dateText {
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: capitalize;
letter-spacing: 0.01em;
}

View file

@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import styles from './AnalogClock.module.css';
// ── SVG-Konstanten ────────────────────────────────────────────────────────────
const CX = 50;
const CY = 50;
const R = 43;
/** Berechnet die Spitzenkoordinaten eines Uhrzeigers. */
function handTip(
cx: number,
cy: number,
deg: number,
length: number,
): { x: number; y: number } {
// 0° = 12 Uhr, im Uhrzeigersinn
const rad = ((deg - 90) * Math.PI) / 180;
return {
x: cx + length * Math.cos(rad),
y: cy + length * Math.sin(rad),
};
}
// ── Komponente ────────────────────────────────────────────────────────────────
export function AnalogClock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const h = now.getHours() % 12;
const m = now.getMinutes();
const s = now.getSeconds();
const hourDeg = h * 30 + m * 0.5; // 360° / 12h
const minuteDeg = m * 6 + s * 0.1; // 360° / 60min
const secondDeg = s * 6; // 360° / 60s
const hTip = handTip(CX, CY, hourDeg, 26);
const mTip = handTip(CX, CY, minuteDeg, 34);
const sTip = handTip(CX, CY, secondDeg, 37);
const sBase = handTip(CX, CY, secondDeg + 180, 10); // Gegengewicht
const timeStr = now.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
const dateStr = now.toLocaleDateString('de-DE', {
weekday: 'short',
day: '2-digit',
month: 'short',
});
return (
<div className={styles.root}>
<svg
viewBox="0 0 100 100"
className={styles.svg}
aria-label={`Uhrzeit: ${timeStr}`}
role="img"
>
{/* Ziffernblatt */}
<circle cx={CX} cy={CY} r={R} className={styles.face} />
<circle cx={CX} cy={CY} r={R} fill="none" className={styles.border} />
{/* Stundenmarkierungen */}
{Array.from({ length: 12 }, (_, i) => {
const angle = (i * 30 - 90) * (Math.PI / 180);
const big = i % 3 === 0;
const outer = R - 1.5;
const inner = outer - (big ? 6 : 3.5);
return (
<line
key={i}
x1={CX + outer * Math.cos(angle)}
y1={CY + outer * Math.sin(angle)}
x2={CX + inner * Math.cos(angle)}
y2={CY + inner * Math.sin(angle)}
className={big ? styles.tickMajor : styles.tickMinor}
/>
);
})}
{/* Stundenzeiger */}
<line
x1={CX} y1={CY}
x2={hTip.x} y2={hTip.y}
className={styles.hourHand}
/>
{/* Minutenzeiger */}
<line
x1={CX} y1={CY}
x2={mTip.x} y2={mTip.y}
className={styles.minuteHand}
/>
{/* Sekundenzeiger mit Gegengewicht */}
<line
x1={sBase.x} y1={sBase.y}
x2={sTip.x} y2={sTip.y}
className={styles.secondHand}
/>
{/* Mittelpunkt */}
<circle cx={CX} cy={CY} r={2.5} className={styles.centerDot} />
</svg>
<div className={styles.timeDigital}>{timeStr}</div>
<div className={styles.dateText}>{dateStr}</div>
</div>
);
}

View file

@ -19,12 +19,27 @@ interface WeatherResponse {
weather_code: number;
is_day: number;
};
daily: {
time: string[];
weather_code: number[];
temperature_2m_max: number[];
temperature_2m_min: number[];
};
}
// ============================================================
// Public Types
// ============================================================
export interface ForecastDay {
date: string; // ISO yyyy-mm-dd
weatherCode: number;
tempMax: number;
tempMin: number;
icon: string;
label: string;
}
export interface WeatherData {
temperature: number;
weatherCode: number;
@ -32,6 +47,7 @@ export interface WeatherData {
cityName: string;
icon: string;
label: string;
forecast: ForecastDay[];
}
// ============================================================
@ -87,11 +103,16 @@ export function useWeather(city: string | null | undefined) {
const geoResult = geocoding.data?.results?.[0];
// Schritt 2: Koordinaten -> Aktuelles Wetter
// Schritt 2: Koordinaten -> Aktuelles Wetter + 3-Tage-Prognose
const weather = useQuery<WeatherResponse>({
queryKey: ['weather', geoResult?.latitude, geoResult?.longitude],
queryFn: async () => {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${geoResult!.latitude}&longitude=${geoResult!.longitude}&current=temperature_2m,weather_code,is_day&timezone=auto`;
const url =
`https://api.open-meteo.com/v1/forecast` +
`?latitude=${geoResult!.latitude}&longitude=${geoResult!.longitude}` +
`&current=temperature_2m,weather_code,is_day` +
`&daily=weather_code,temperature_2m_max,temperature_2m_min` +
`&forecast_days=3&timezone=auto`;
const res = await fetch(url);
if (!res.ok) throw new Error('Wetter-Abfrage fehlgeschlagen');
return res.json() as Promise<WeatherResponse>;
@ -107,6 +128,23 @@ export function useWeather(city: string | null | undefined) {
? (() => {
const { temperature_2m, weather_code, is_day } = weather.data.current;
const info = getWeatherInfo(weather_code, is_day === 1);
// 3-Tage-Prognose
const forecast: ForecastDay[] = (weather.data.daily?.time ?? []).map(
(date, i) => {
const code = weather.data!.daily.weather_code[i] ?? 0;
const fi = getWeatherInfo(code, true); // Tagessymbole für Prognose
return {
date,
weatherCode: code,
tempMax: Math.round(weather.data!.daily.temperature_2m_max[i] ?? 0),
tempMin: Math.round(weather.data!.daily.temperature_2m_min[i] ?? 0),
icon: fi.icon,
label: fi.label,
};
},
);
return {
temperature: temperature_2m,
weatherCode: weather_code,
@ -114,6 +152,7 @@ export function useWeather(city: string | null | undefined) {
cityName: geoResult.name,
icon: info.icon,
label: info.label,
forecast,
};
})()
: undefined;

View file

@ -296,6 +296,344 @@
color: #86efac;
}
/* ── Spruch des Tages ── */
.quoteOfDay {
display: flex;
flex-direction: column;
align-items: flex-end;
max-width: 420px;
flex-shrink: 1;
opacity: 0.78;
}
.quoteText {
font-size: 0.8125rem;
font-style: italic;
color: var(--color-text-secondary);
text-align: right;
line-height: 1.45;
}
.quoteAuthor {
font-size: 0.6875rem;
color: var(--color-text-muted);
text-align: right;
margin-top: 0.125rem;
}
/* ── Linke Spalte: Uhr + Wetter + Prognose ── */
.homeLeft {
width: 240px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.875rem;
}
.homeWeatherBox {
width: 100%;
display: flex;
justify-content: center;
}
.forecastStrip {
width: 100%;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.625rem 0.75rem;
box-shadow: var(--shadow-sm);
}
.forecastTitle {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
margin: 0 0 0.5rem 0;
}
.forecastDays {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.forecastDay {
display: flex;
align-items: center;
gap: 0.5rem;
}
.forecastDayLabel {
font-size: 0.75rem;
color: var(--color-text-secondary);
flex: 1;
min-width: 0;
}
.forecastDayIcon {
font-size: 1rem;
line-height: 1;
}
.forecastDayTemp {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
}
.forecastDayTempMin {
font-weight: 400;
color: var(--color-text-muted);
}
/* ── Home-Widget-Karten (mittlere Spalte) ── */
.homeWidgetCard {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.homeWidgetHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5625rem 0.875rem;
border-bottom: 1px solid var(--color-border);
}
.homeWidgetTitle {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
margin: 0;
}
.homeWidgetAll {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-primary);
text-decoration: none;
background: none;
border: none;
padding: 0;
cursor: pointer;
}
.homeWidgetAll:hover {
text-decoration: underline;
}
.homeWidgetFooter {
padding: 0.4375rem 0.875rem;
font-size: 0.75rem;
color: var(--color-text-muted);
border-top: 1px solid var(--color-border);
text-align: center;
}
/* Kompakte Aufgaben-Zeilen */
.homeTaskList {
display: flex;
flex-direction: column;
}
.homeTaskRow {
display: flex;
align-items: center;
gap: 0.4375rem;
padding: 0.4375rem 0.875rem;
border-bottom: 1px solid var(--color-border);
transition: background 0.1s;
}
.homeTaskRow:last-child {
border-bottom: none;
}
.homeTaskRow:hover {
background: var(--color-bg-subtle);
}
.homeTaskBadge {
font-size: 0.5rem;
font-weight: 800;
text-transform: uppercase;
padding: 0.1rem 0.25rem;
border-radius: 3px;
flex-shrink: 0;
letter-spacing: 0.03em;
}
.homeTaskBadgeO365 {
background: #dbeafe;
color: #1e40af;
}
.homeTaskBadgeCrm {
background: #f0fdf4;
color: #166534;
}
:global([data-theme='dark']) .homeTaskBadgeO365 {
background: rgba(59, 130, 246, 0.15);
color: #93c5fd;
}
:global([data-theme='dark']) .homeTaskBadgeCrm {
background: rgba(34, 197, 94, 0.12);
color: #86efac;
}
.homeTaskMain {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
gap: 0.375rem;
overflow: hidden;
}
.homeTaskTitle {
font-size: 0.8125rem;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.homeTaskDue {
font-size: 0.6875rem;
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.homeTaskDueOverdue {
color: #ef4444;
font-weight: 600;
}
.homeTaskComplete {
background: none;
border: 1.5px solid var(--color-border);
color: var(--color-text-muted);
width: 22px;
height: 22px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
flex-shrink: 0;
padding: 0;
transition: border-color 0.15s, color 0.15s;
}
.homeTaskComplete:hover:not(:disabled) {
border-color: #22c55e;
color: #22c55e;
}
.homeTaskComplete:disabled {
opacity: 0.45;
cursor: default;
}
/* Kompakte E-Mail-Zeilen */
.homeEmailList {
display: flex;
flex-direction: column;
}
.homeEmailRow {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.4375rem 0.875rem;
border-bottom: 1px solid var(--color-border);
text-decoration: none;
color: inherit;
transition: background 0.1s;
}
.homeEmailRow:last-child {
border-bottom: none;
}
.homeEmailRow:hover {
background: var(--color-bg-subtle);
}
.homeEmailUnread {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-primary);
flex-shrink: 0;
margin-top: 0.3rem;
}
.homeEmailReadDot {
width: 7px;
height: 7px;
flex-shrink: 0;
}
.homeEmailContent {
flex: 1;
min-width: 0;
}
.homeEmailSender {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.homeEmailSubject {
font-size: 0.75rem;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
.homeEmailTime {
font-size: 0.6875rem;
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
padding-top: 0.125rem;
}
.homeEmptyHint {
padding: 0.875rem;
font-size: 0.8125rem;
color: var(--color-text-muted);
text-align: center;
}
/* ── Platzhalter für bestehenden Home-Inhalt ── */
.placeholder {

View file

@ -1,14 +1,81 @@
import { useState } from 'react';
import { useAuth } from '../auth/AuthContext';
import { WeatherWidget } from '../components/WeatherWidget';
import { AnalogClock } from '../components/AnalogClock';
import { DashboardEmailTab } from './DashboardEmailTab';
import { DashboardCalendarTab, DayAgenda } from './DashboardCalendarTab';
import { DashboardTasksTab } from './DashboardTasksTab';
import { useIntegrations, useOffice365CalendarRange, useActiveTradeEvents } from '../crm/hooks';
import {
useIntegrations,
useOffice365CalendarRange,
useActiveTradeEvents,
useOffice365Emails,
useOffice365TasksFlat,
useCrmOpenTasks,
useCompleteO365Task,
useCompleteCrmTask,
} from '../crm/hooks';
import { useEventCountdown } from '../hooks/useEventCountdown';
import type { M365CalendarEvent, TradeEvent } from '../crm/types';
import { useWeather } from '../hooks/useWeather';
import type { M365CalendarEvent, TradeEvent, M365Email } from '../crm/types';
import type { M365TaskFlat, CrmOpenTask } from '../crm/types';
import styles from './DashboardPage.module.css';
// ── Spruch des Tages ──────────────────────────────────────────────────────────
const QUOTES: { text: string; author: string }[] = [
{ text: 'Der Weg ist das Ziel.', author: 'Konfuzius' },
{ text: 'Erfolg hat drei Buchstaben: Tun.', author: 'J. W. von Goethe' },
{ text: 'Wer aufhört, besser zu werden, hat aufgehört, gut zu sein.', author: 'Philip Rosenthal' },
{ text: 'Die Fantasie ist wichtiger als das Wissen.', author: 'Albert Einstein' },
{ text: 'Wer kämpft, kann verlieren. Wer nicht kämpft, hat schon verloren.', author: 'Bertolt Brecht' },
{ text: 'Sei du selbst die Veränderung, die du dir wünschst für diese Welt.', author: 'Mahatma Gandhi' },
{ text: 'Wenn der Wind der Veränderung weht, bauen die einen Mauern und die anderen Windmühlen.', author: 'Chinesisches Sprichwort' },
{ text: 'Das Geheimnis des Erfolgs ist, den Standpunkt des anderen zu verstehen.', author: 'Henry Ford' },
{ text: 'Tu erst das Notwendige, dann das Mögliche, und plötzlich schaffst du das Unmögliche.', author: 'Franz von Assisi' },
{ text: 'Man sieht nur mit dem Herzen gut. Das Wesentliche ist für die Augen unsichtbar.', author: 'Antoine de Saint-Exupéry' },
{ text: 'Nicht die Stärksten überleben, sondern die Anpassungsfähigsten.', author: 'Charles Darwin' },
{ text: 'Ein langer Marsch beginnt mit dem ersten Schritt.', author: 'Laotse' },
{ text: 'Wer wagt, gewinnt.', author: 'Volksweisheit' },
{ text: 'Jeder Experte war einmal ein Anfänger.', author: 'Unbekannt' },
{ text: 'In der Mitte liegt die Kraft.', author: 'Sprichwort' },
{ text: 'Nicht wer wenig hat, sondern wer viel begehrt, ist arm.', author: 'Seneca' },
{ text: 'Kreativität ist Intelligenz, die Spaß hat.', author: 'Albert Einstein' },
{ text: 'Stärke zeigt sich nicht darin, niemals zu fallen, sondern darin, nach jedem Fall aufzustehen.', author: 'Nelson Mandela' },
{ text: 'Der beste Zeitpunkt, einen Baum zu pflanzen, war vor zwanzig Jahren. Der zweitbeste ist jetzt.', author: 'Chinesisches Sprichwort' },
{ text: 'Übung macht den Meister.', author: 'Volksweisheit' },
{ text: 'Es ist nicht genug zu wissen — man muss auch anwenden.', author: 'J. W. von Goethe' },
{ text: 'Optimismus ist die Grundlage des Mutes.', author: 'Nick Butler' },
{ text: 'Wo ein Wille ist, ist auch ein Weg.', author: 'Volksweisheit' },
{ text: 'Die größte Entdeckungsreise liegt nicht darin, neue Länder zu suchen, sondern darin, die Dinge mit neuen Augen zu sehen.', author: 'Marcel Proust' },
{ text: 'Der Klügere gibt nach — eine traurige Wahrheit, sie begründet die Weltherrschaft der Dummheit.', author: 'Marie von Ebner-Eschenbach' },
{ text: 'Heute ist der erste Tag vom Rest deines Lebens.', author: 'Sprichwort' },
{ text: 'Ein gutes Gewissen ist ein sanftes Ruhekissen.', author: 'Volksweisheit' },
{ text: 'Kein Mensch hat das Recht, das zu unterlassen, was er kann.', author: 'Albert Schweitzer' },
{ text: 'Wissen ist Macht.', author: 'Francis Bacon' },
{ text: 'Lächle und die Welt lächelt zurück.', author: 'Volksweisheit' },
{ text: 'Der Mut macht erst den Mann.', author: 'Friedrich Schiller' },
{ text: 'Ein Lächeln kostet nichts, bringt aber viel.', author: 'Unbekannt' },
{ text: 'Aus Fehlern lernt man — außer man macht sie nicht.', author: 'Sprichwort' },
{ text: 'Denke nicht daran, was du tun könntest, sondern was du tust.', author: 'Unbekannt' },
{ text: 'Die Zeit heilt alle Wunden.', author: 'Volksweisheit' },
];
function getDayOfYear(date: Date): number {
const start = new Date(date.getFullYear(), 0, 0);
return Math.floor((date.getTime() - start.getTime()) / 86_400_000);
}
function QuoteOfTheDay() {
const q = QUOTES[getDayOfYear(new Date()) % QUOTES.length];
return (
<div className={styles.quoteOfDay}>
<span className={styles.quoteText}>{q.text}"</span>
<span className={styles.quoteAuthor}> {q.author}</span>
</div>
);
}
// ── Messe-Ticker: Hilfsfunktionen ─────────────────────────────────────────────
function isStillRelevant(event: TradeEvent): boolean {
@ -180,15 +247,327 @@ function CompactMesseTicker() {
);
}
type DashboardTab = 'home' | 'emails' | 'calendar' | 'tasks' | 'contacts';
// ── Linke Spalte: Uhr + Wetter + 3-Tage-Prognose ─────────────────────────────
const TABS: { id: DashboardTab; label: string }[] = [
{ id: 'home', label: 'Home' },
{ id: 'emails', label: 'E-Mail' },
{ id: 'calendar', label: 'Kalender' },
{ id: 'tasks', label: 'Aufgaben' },
{ id: 'contacts', label: 'Kontakte' },
];
function HomeLeftColumn({ city }: { city?: string | null }) {
const { data: weatherData } = useWeather(city ?? undefined);
return (
<div className={styles.homeLeft}>
{/* Analoge Uhr */}
<AnalogClock />
{/* Aktuelles Wetter */}
<div className={styles.homeWeatherBox}>
<WeatherWidget city={city ?? undefined} />
</div>
{/* 3-Tage-Prognose */}
{weatherData?.forecast && weatherData.forecast.length > 0 && (
<div className={styles.forecastStrip}>
<h4 className={styles.forecastTitle}>3-Tage-Prognose</h4>
<div className={styles.forecastDays}>
{weatherData.forecast.map((day) => {
const d = new Date(day.date);
const dayLabel = d.toLocaleDateString('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
});
return (
<div key={day.date} className={styles.forecastDay}>
<span className={styles.forecastDayLabel}>{dayLabel}</span>
<span className={styles.forecastDayIcon}>{day.icon}</span>
<span className={styles.forecastDayTemp}>
{day.tempMax}°{' '}
<span className={styles.forecastDayTempMin}>{day.tempMin}°</span>
</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
// ── Mittlere Spalte: Kompakte Aufgaben ────────────────────────────────────────
const CRM_MARKER_RE = /\[INSIGHT_CRM:([^\]]+)\]/;
function extractCrmId(body: string | null | undefined): string | null {
if (!body) return null;
const m = CRM_MARKER_RE.exec(body);
return m ? m[1] : null;
}
function formatDue(isoOrDateTime: string | null): string | null {
if (!isoOrDateTime) return null;
try {
const d = new Date(isoOrDateTime);
const today = new Date();
const todayStr = today.toISOString().slice(0, 10);
const dStr = d.toISOString().slice(0, 10);
const tomorrow = new Date(today.getTime() + 86_400_000).toISOString().slice(0, 10);
if (dStr === todayStr) return 'Heute';
if (dStr === tomorrow) return 'Morgen';
if (dStr < todayStr) {
const days = Math.round((today.getTime() - d.getTime()) / 86_400_000);
return `Überfällig (${days}d)`;
}
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
} catch {
return null;
}
}
function isDue(iso: string | null): boolean {
if (!iso) return false;
try { return new Date(iso) < new Date(); } catch { return false; }
}
function HomeTasksWidget({ onSeeAll }: { onSeeAll?: () => void }) {
const { data: integrationsData } = useIntegrations();
const isO365Connected =
integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false;
const { data: o365Data, isLoading: o365Loading } = useOffice365TasksFlat();
const { data: crmData, isLoading: crmLoading } = useCrmOpenTasks();
const completeO365 = useCompleteO365Task();
const completeCrm = useCompleteCrmTask();
const [pendingKey, setPendingKey] = useState<string | null>(null);
const o365Tasks: M365TaskFlat[] = o365Data?.data ?? [];
const crmTasks: CrmOpenTask[] = crmData?.data ?? [];
// CRM-IDs die bereits in O365 sind
const syncedCrmIds = new Set<string>();
for (const t of o365Tasks) {
const id = extractCrmId(t.bodyContent);
if (id) syncedCrmIds.add(id);
}
// Unified list (gleiche Logik wie DashboardTasksTab)
type TaskSource = 'o365' | 'crm' | 'synced';
interface HomeTask {
key: string;
source: TaskSource;
title: string;
dueDate: string | null;
o365ListId?: string;
o365TaskId?: string;
crmActivityId?: string;
}
const unified: HomeTask[] = [];
for (const t of o365Tasks) {
const crmId = extractCrmId(t.bodyContent);
unified.push({
key: `o365-${t.id}`,
source: crmId ? 'synced' : 'o365',
title: t.title,
dueDate: t.dueDateTime?.dateTime ?? null,
o365ListId: t.listId,
o365TaskId: t.id,
crmActivityId: crmId ?? undefined,
});
}
for (const c of crmTasks) {
if (syncedCrmIds.has(c.id)) continue;
unified.push({
key: `crm-${c.id}`,
source: 'crm',
title: c.subject,
dueDate: c.scheduledAt,
crmActivityId: c.id,
});
}
unified.sort((a, b) => {
const aO = a.dueDate && isDue(a.dueDate);
const bO = b.dueDate && isDue(b.dueDate);
if (aO && !bO) return -1;
if (!aO && bO) return 1;
if (a.dueDate && b.dueDate) return a.dueDate.localeCompare(b.dueDate);
if (a.dueDate) return -1;
if (b.dueDate) return 1;
return 0;
});
const visible = unified.slice(0, 8);
const isLoading = (isO365Connected && o365Loading) || crmLoading;
function handleComplete(task: HomeTask) {
setPendingKey(task.key);
const ps: Promise<unknown>[] = [];
if (task.o365TaskId && task.o365ListId) {
ps.push(completeO365.mutateAsync({ listId: task.o365ListId, taskId: task.o365TaskId }));
}
if (task.crmActivityId) {
ps.push(completeCrm.mutateAsync(task.crmActivityId));
}
Promise.allSettled(ps).finally(() => setPendingKey(null));
}
return (
<div className={styles.homeWidgetCard}>
<div className={styles.homeWidgetHeader}>
<h4 className={styles.homeWidgetTitle}>Aufgaben</h4>
{onSeeAll && (
<button type="button" className={styles.homeWidgetAll} onClick={onSeeAll}>
Alle
</button>
)}
</div>
{isLoading && (
<p className={styles.homeEmptyHint}>Lädt</p>
)}
{!isLoading && visible.length === 0 && (
<p className={styles.homeEmptyHint}> Keine offenen Aufgaben</p>
)}
{!isLoading && visible.length > 0 && (
<div className={styles.homeTaskList}>
{visible.map((task) => {
const overdue = isDue(task.dueDate);
const dueFmt = formatDue(task.dueDate);
return (
<div key={task.key} className={styles.homeTaskRow}>
{/* Source-Badge */}
{(task.source === 'o365' || task.source === 'synced') && (
<span className={`${styles.homeTaskBadge} ${styles.homeTaskBadgeO365}`}>O</span>
)}
{task.source === 'crm' && (
<span className={`${styles.homeTaskBadge} ${styles.homeTaskBadgeCrm}`}>C</span>
)}
<div className={styles.homeTaskMain}>
<span className={styles.homeTaskTitle}>{task.title}</span>
{dueFmt && (
<span
className={`${styles.homeTaskDue} ${overdue ? styles.homeTaskDueOverdue : ''}`}
>
{dueFmt}
</span>
)}
</div>
<button
type="button"
className={styles.homeTaskComplete}
onClick={() => handleComplete(task)}
disabled={pendingKey === task.key}
title="Als erledigt markieren"
>
{pendingKey === task.key ? '…' : '✓'}
</button>
</div>
);
})}
</div>
)}
{unified.length > 8 && (
<div className={styles.homeWidgetFooter}>
{unified.length - 8} weitere Aufgaben
</div>
)}
</div>
);
}
// ── Mittlere Spalte: Kompakte E-Mails (letzte 3 Tage) ────────────────────────
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' });
}
function HomeEmailsWidget({ onSeeAll }: { onSeeAll?: () => void }) {
const { data: integrationsData } = useIntegrations();
const isConnected =
integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false;
const { data: emailData, isLoading } = useOffice365Emails();
if (!isConnected) return null;
// Letzte 3 Tage filtern
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 3);
const emails: M365Email[] = (emailData?.data ?? []).filter(
(e) => new Date(e.receivedDateTime) >= cutoff,
);
return (
<div className={styles.homeWidgetCard}>
<div className={styles.homeWidgetHeader}>
<h4 className={styles.homeWidgetTitle}>E-Mails (3 Tage)</h4>
{onSeeAll && (
<button type="button" className={styles.homeWidgetAll} onClick={onSeeAll}>
Alle
</button>
)}
</div>
{isLoading && <p className={styles.homeEmptyHint}>Lädt</p>}
{!isLoading && emails.length === 0 && (
<p className={styles.homeEmptyHint}>Keine E-Mails in den letzten 3 Tagen</p>
)}
{!isLoading && emails.length > 0 && (
<div className={styles.homeEmailList}>
{emails.slice(0, 8).map((email) => {
const sender =
email.from?.emailAddress?.name ??
email.from?.emailAddress?.address ??
'Unbekannt';
const subject = email.subject ?? '(kein Betreff)';
const timeStr = formatEmailTime(email.receivedDateTime);
return (
<a
key={email.id}
href={email.webLink}
target="_blank"
rel="noopener noreferrer"
className={styles.homeEmailRow}
>
{!email.isRead ? (
<span className={styles.homeEmailUnread} title="Ungelesen" />
) : (
<span className={styles.homeEmailReadDot} />
)}
<div className={styles.homeEmailContent}>
<div className={styles.homeEmailSender}>{sender}</div>
<div className={styles.homeEmailSubject}>{subject}</div>
</div>
<span className={styles.homeEmailTime}>{timeStr}</span>
</a>
);
})}
</div>
)}
{emails.length > 8 && (
<div className={styles.homeWidgetFooter}>{emails.length - 8} weitere E-Mails</div>
)}
</div>
);
}
// ── Sidebar: Messe-Ticker + Tages-Agenda ─────────────────────────────────────
@ -208,10 +587,8 @@ function HomeSidebar() {
return (
<div className={styles.homeSidebar}>
{/* Kompakter Messe-Ticker immer oben */}
<CompactMesseTicker />
{/* Tages-Agenda nur wenn O365 verbunden */}
{isConnected && (
isLoading
? <p style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)', marginTop: '0.5rem' }}>
@ -223,33 +600,53 @@ function HomeSidebar() {
);
}
// ── Tab-Inhalte ───────────────────────────────────────────────────────────────
// ── Tab-Definitionen ──────────────────────────────────────────────────────────
function HomeTab({ firstName, lastName, city, role }: {
type DashboardTab = 'home' | 'emails' | 'calendar' | 'tasks' | 'contacts';
const TABS: { id: DashboardTab; label: string }[] = [
{ id: 'home', label: 'Home' },
{ id: 'emails', label: 'E-Mail' },
{ id: 'calendar', label: 'Kalender' },
{ id: 'tasks', label: 'Aufgaben' },
{ id: 'contacts', label: 'Kontakte' },
];
// ── HomeTab ───────────────────────────────────────────────────────────────────
function HomeTab({
firstName,
lastName,
city,
onSwitchTab,
}: {
firstName?: string;
lastName?: string;
city?: string | null;
role?: string;
onSwitchTab: (tab: DashboardTab) => void;
}) {
return (
<>
{/* Header: Name links, Spruch rechts */}
<div className={styles.header}>
<h1 className={styles.title}>
Willkommen, {firstName} {lastName}
</h1>
<WeatherWidget city={city ?? undefined} />
<QuoteOfTheDay />
</div>
{/* 3-Spalten-Layout */}
<div className={styles.homeLayout}>
{/* Links: Uhr + Wetter + Prognose */}
<HomeLeftColumn city={city} />
{/* Mitte: Aufgaben + E-Mails */}
<div className={styles.homeMain}>
<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>
<HomeTasksWidget onSeeAll={() => onSwitchTab('tasks')} />
<HomeEmailsWidget onSeeAll={() => onSwitchTab('emails')} />
</div>
{/* Rechts: Messe-Ticker + Tagesagenda */}
<HomeSidebar />
</div>
</>
@ -295,7 +692,7 @@ export function DashboardPage() {
firstName={user?.firstName}
lastName={user?.lastName}
city={user?.city}
role={user?.role}
onSwitchTab={setActiveTab}
/>
)}
{activeTab === 'emails' && <DashboardEmailTab />}