mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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:
parent
2348602fb0
commit
ceea82a2ac
5 changed files with 1000 additions and 29 deletions
80
packages/frontend/src/components/AnalogClock.module.css
Normal file
80
packages/frontend/src/components/AnalogClock.module.css
Normal 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;
|
||||||
|
}
|
||||||
117
packages/frontend/src/components/AnalogClock.tsx
Normal file
117
packages/frontend/src/components/AnalogClock.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -19,12 +19,27 @@ interface WeatherResponse {
|
||||||
weather_code: number;
|
weather_code: number;
|
||||||
is_day: number;
|
is_day: number;
|
||||||
};
|
};
|
||||||
|
daily: {
|
||||||
|
time: string[];
|
||||||
|
weather_code: number[];
|
||||||
|
temperature_2m_max: number[];
|
||||||
|
temperature_2m_min: number[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Public Types
|
// Public Types
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
export interface ForecastDay {
|
||||||
|
date: string; // ISO yyyy-mm-dd
|
||||||
|
weatherCode: number;
|
||||||
|
tempMax: number;
|
||||||
|
tempMin: number;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WeatherData {
|
export interface WeatherData {
|
||||||
temperature: number;
|
temperature: number;
|
||||||
weatherCode: number;
|
weatherCode: number;
|
||||||
|
|
@ -32,6 +47,7 @@ export interface WeatherData {
|
||||||
cityName: string;
|
cityName: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
forecast: ForecastDay[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -87,11 +103,16 @@ export function useWeather(city: string | null | undefined) {
|
||||||
|
|
||||||
const geoResult = geocoding.data?.results?.[0];
|
const geoResult = geocoding.data?.results?.[0];
|
||||||
|
|
||||||
// Schritt 2: Koordinaten -> Aktuelles Wetter
|
// Schritt 2: Koordinaten -> Aktuelles Wetter + 3-Tage-Prognose
|
||||||
const weather = useQuery<WeatherResponse>({
|
const weather = useQuery<WeatherResponse>({
|
||||||
queryKey: ['weather', geoResult?.latitude, geoResult?.longitude],
|
queryKey: ['weather', geoResult?.latitude, geoResult?.longitude],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${geoResult!.latitude}&longitude=${geoResult!.longitude}¤t=temperature_2m,weather_code,is_day&timezone=auto`;
|
const url =
|
||||||
|
`https://api.open-meteo.com/v1/forecast` +
|
||||||
|
`?latitude=${geoResult!.latitude}&longitude=${geoResult!.longitude}` +
|
||||||
|
`¤t=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);
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error('Wetter-Abfrage fehlgeschlagen');
|
if (!res.ok) throw new Error('Wetter-Abfrage fehlgeschlagen');
|
||||||
return res.json() as Promise<WeatherResponse>;
|
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 { temperature_2m, weather_code, is_day } = weather.data.current;
|
||||||
const info = getWeatherInfo(weather_code, is_day === 1);
|
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 {
|
return {
|
||||||
temperature: temperature_2m,
|
temperature: temperature_2m,
|
||||||
weatherCode: weather_code,
|
weatherCode: weather_code,
|
||||||
|
|
@ -114,6 +152,7 @@ export function useWeather(city: string | null | undefined) {
|
||||||
cityName: geoResult.name,
|
cityName: geoResult.name,
|
||||||
icon: info.icon,
|
icon: info.icon,
|
||||||
label: info.label,
|
label: info.label,
|
||||||
|
forecast,
|
||||||
};
|
};
|
||||||
})()
|
})()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,344 @@
|
||||||
color: #86efac;
|
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 ── */
|
/* ── Platzhalter für bestehenden Home-Inhalt ── */
|
||||||
|
|
||||||
.placeholder {
|
.placeholder {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,81 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { WeatherWidget } from '../components/WeatherWidget';
|
import { WeatherWidget } from '../components/WeatherWidget';
|
||||||
|
import { AnalogClock } from '../components/AnalogClock';
|
||||||
import { DashboardEmailTab } from './DashboardEmailTab';
|
import { DashboardEmailTab } from './DashboardEmailTab';
|
||||||
import { DashboardCalendarTab, DayAgenda } from './DashboardCalendarTab';
|
import { DashboardCalendarTab, DayAgenda } from './DashboardCalendarTab';
|
||||||
import { DashboardTasksTab } from './DashboardTasksTab';
|
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 { 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';
|
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 ─────────────────────────────────────────────
|
// ── Messe-Ticker: Hilfsfunktionen ─────────────────────────────────────────────
|
||||||
|
|
||||||
function isStillRelevant(event: TradeEvent): boolean {
|
function isStillRelevant(event: TradeEvent): boolean {
|
||||||
|
|
@ -180,21 +247,333 @@ function CompactMesseTicker() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type DashboardTab = 'home' | 'emails' | 'calendar' | 'tasks' | 'contacts';
|
// ── Linke Spalte: Uhr + Wetter + 3-Tage-Prognose ─────────────────────────────
|
||||||
|
|
||||||
const TABS: { id: DashboardTab; label: string }[] = [
|
function HomeLeftColumn({ city }: { city?: string | null }) {
|
||||||
{ id: 'home', label: 'Home' },
|
const { data: weatherData } = useWeather(city ?? undefined);
|
||||||
{ id: 'emails', label: 'E-Mail' },
|
|
||||||
{ id: 'calendar', label: 'Kalender' },
|
return (
|
||||||
{ id: 'tasks', label: 'Aufgaben' },
|
<div className={styles.homeLeft}>
|
||||||
{ id: 'contacts', label: 'Kontakte' },
|
{/* 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 ─────────────────────────────────────
|
// ── Sidebar: Messe-Ticker + Tages-Agenda ─────────────────────────────────────
|
||||||
|
|
||||||
function HomeSidebar() {
|
function HomeSidebar() {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const todayISO = today.toISOString().slice(0, 10);
|
const todayISO = today.toISOString().slice(0, 10);
|
||||||
const tomorrowISO = new Date(today.getTime() + 86_400_000).toISOString().slice(0, 10);
|
const tomorrowISO = new Date(today.getTime() + 86_400_000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
const { data: integrationsData } = useIntegrations();
|
const { data: integrationsData } = useIntegrations();
|
||||||
|
|
@ -208,10 +587,8 @@ function HomeSidebar() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.homeSidebar}>
|
<div className={styles.homeSidebar}>
|
||||||
{/* Kompakter Messe-Ticker immer oben */}
|
|
||||||
<CompactMesseTicker />
|
<CompactMesseTicker />
|
||||||
|
|
||||||
{/* Tages-Agenda nur wenn O365 verbunden */}
|
|
||||||
{isConnected && (
|
{isConnected && (
|
||||||
isLoading
|
isLoading
|
||||||
? <p style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)', marginTop: '0.5rem' }}>
|
? <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;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
city?: string | null;
|
city?: string | null;
|
||||||
role?: string;
|
onSwitchTab: (tab: DashboardTab) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Header: Name links, Spruch rechts */}
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h1 className={styles.title}>
|
<h1 className={styles.title}>
|
||||||
Willkommen, {firstName} {lastName}
|
Willkommen, {firstName} {lastName}
|
||||||
</h1>
|
</h1>
|
||||||
<WeatherWidget city={city ?? undefined} />
|
<QuoteOfTheDay />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 3-Spalten-Layout */}
|
||||||
<div className={styles.homeLayout}>
|
<div className={styles.homeLayout}>
|
||||||
|
{/* Links: Uhr + Wetter + Prognose */}
|
||||||
|
<HomeLeftColumn city={city} />
|
||||||
|
|
||||||
|
{/* Mitte: Aufgaben + E-Mails */}
|
||||||
<div className={styles.homeMain}>
|
<div className={styles.homeMain}>
|
||||||
<div className={styles.placeholder}>
|
<HomeTasksWidget onSeeAll={() => onSwitchTab('tasks')} />
|
||||||
<p style={{ color: 'var(--color-text-secondary)' }}>
|
<HomeEmailsWidget onSeeAll={() => onSwitchTab('emails')} />
|
||||||
INSIGHT Platform - Sprint 1 Alpha
|
|
||||||
</p>
|
|
||||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', marginTop: '0.5rem' }}>
|
|
||||||
Rolle: {role}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Rechts: Messe-Ticker + Tagesagenda */}
|
||||||
<HomeSidebar />
|
<HomeSidebar />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -295,7 +692,7 @@ export function DashboardPage() {
|
||||||
firstName={user?.firstName}
|
firstName={user?.firstName}
|
||||||
lastName={user?.lastName}
|
lastName={user?.lastName}
|
||||||
city={user?.city}
|
city={user?.city}
|
||||||
role={user?.role}
|
onSwitchTab={setActiveTab}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'emails' && <DashboardEmailTab />}
|
{activeTab === 'emails' && <DashboardEmailTab />}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue