diff --git a/packages/frontend/src/components/AnalogClock.module.css b/packages/frontend/src/components/AnalogClock.module.css new file mode 100644 index 0000000..390f3f6 --- /dev/null +++ b/packages/frontend/src/components/AnalogClock.module.css @@ -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; +} diff --git a/packages/frontend/src/components/AnalogClock.tsx b/packages/frontend/src/components/AnalogClock.tsx new file mode 100644 index 0000000..54a6b7a --- /dev/null +++ b/packages/frontend/src/components/AnalogClock.tsx @@ -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 ( +
+ + {/* Ziffernblatt */} + + + + {/* 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 ( + + ); + })} + + {/* Stundenzeiger */} + + + {/* Minutenzeiger */} + + + {/* Sekundenzeiger mit Gegengewicht */} + + + {/* Mittelpunkt */} + + + +
{timeStr}
+
{dateStr}
+
+ ); +} diff --git a/packages/frontend/src/hooks/useWeather.ts b/packages/frontend/src/hooks/useWeather.ts index b2bc393..0d9204f 100644 --- a/packages/frontend/src/hooks/useWeather.ts +++ b/packages/frontend/src/hooks/useWeather.ts @@ -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({ queryKey: ['weather', geoResult?.latitude, geoResult?.longitude], 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); if (!res.ok) throw new Error('Wetter-Abfrage fehlgeschlagen'); return res.json() as Promise; @@ -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; diff --git a/packages/frontend/src/shell/DashboardPage.module.css b/packages/frontend/src/shell/DashboardPage.module.css index 37f9fdb..8c89842 100644 --- a/packages/frontend/src/shell/DashboardPage.module.css +++ b/packages/frontend/src/shell/DashboardPage.module.css @@ -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 { diff --git a/packages/frontend/src/shell/DashboardPage.tsx b/packages/frontend/src/shell/DashboardPage.tsx index 2d76239..3c6711d 100644 --- a/packages/frontend/src/shell/DashboardPage.tsx +++ b/packages/frontend/src/shell/DashboardPage.tsx @@ -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 ( +
+ „{q.text}" + — {q.author} +
+ ); +} + // ── Messe-Ticker: Hilfsfunktionen ───────────────────────────────────────────── 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 }[] = [ - { 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 ( +
+ {/* Analoge Uhr */} + + + {/* Aktuelles Wetter */} +
+ +
+ + {/* 3-Tage-Prognose */} + {weatherData?.forecast && weatherData.forecast.length > 0 && ( +
+

3-Tage-Prognose

+
+ {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 ( +
+ {dayLabel} + {day.icon} + + {day.tempMax}°{' '} + {day.tempMin}° + +
+ ); + })} +
+
+ )} +
+ ); +} + +// ── 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(null); + + const o365Tasks: M365TaskFlat[] = o365Data?.data ?? []; + const crmTasks: CrmOpenTask[] = crmData?.data ?? []; + + // CRM-IDs die bereits in O365 sind + const syncedCrmIds = new Set(); + 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[] = []; + 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 ( +
+
+

Aufgaben

+ {onSeeAll && ( + + )} +
+ + {isLoading && ( +

Lädt…

+ )} + + {!isLoading && visible.length === 0 && ( +

✅ Keine offenen Aufgaben

+ )} + + {!isLoading && visible.length > 0 && ( +
+ {visible.map((task) => { + const overdue = isDue(task.dueDate); + const dueFmt = formatDue(task.dueDate); + return ( +
+ {/* Source-Badge */} + {(task.source === 'o365' || task.source === 'synced') && ( + O + )} + {task.source === 'crm' && ( + C + )} + +
+ {task.title} + {dueFmt && ( + + {dueFmt} + + )} +
+ + +
+ ); + })} +
+ )} + + {unified.length > 8 && ( +
+ {unified.length - 8} weitere Aufgaben +
+ )} +
+ ); +} + +// ── 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 ( +
+
+

E-Mails (3 Tage)

+ {onSeeAll && ( + + )} +
+ + {isLoading &&

Lädt…

} + + {!isLoading && emails.length === 0 && ( +

Keine E-Mails in den letzten 3 Tagen

+ )} + + {!isLoading && emails.length > 0 && ( +
+ {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 ( + + {!email.isRead ? ( + + ) : ( + + )} +
+
{sender}
+
{subject}
+
+ {timeStr} +
+ ); + })} +
+ )} + + {emails.length > 8 && ( +
{emails.length - 8} weitere E-Mails
+ )} +
+ ); +} // ── Sidebar: Messe-Ticker + Tages-Agenda ───────────────────────────────────── function HomeSidebar() { - const today = new Date(); - const todayISO = today.toISOString().slice(0, 10); + 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(); @@ -208,10 +587,8 @@ function HomeSidebar() { return (
- {/* Kompakter Messe-Ticker immer oben */} - {/* Tages-Agenda nur wenn O365 verbunden */} {isConnected && ( isLoading ?

@@ -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 */}

Willkommen, {firstName} {lastName}

- +
+ + {/* 3-Spalten-Layout */}
+ {/* Links: Uhr + Wetter + Prognose */} + + + {/* Mitte: Aufgaben + E-Mails */}
-
-

- INSIGHT Platform - Sprint 1 Alpha -

-

- Rolle: {role} -

-
+ onSwitchTab('tasks')} /> + onSwitchTab('emails')} />
+ + {/* Rechts: Messe-Ticker + Tagesagenda */}
@@ -295,7 +692,7 @@ export function DashboardPage() { firstName={user?.firstName} lastName={user?.lastName} city={user?.city} - role={user?.role} + onSwitchTab={setActiveTab} /> )} {activeTab === 'emails' && }