mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
- EmailsTab: Outlook-aehnlicher Detail-Popup beim Klick auf E-Mail (Von/An/Datum/Anhang-Meta, Body-Vorschau, In Kontakt speichern als EMAIL-Aktivitaet) - Neues EmailsTab.module.css fuer kompakte Liste und Modal - ContactDetailPage: Aktivitaeten-Filterleiste (Typ + Zeitraum Von/Bis) - ContactDetailPage: Zeitstrahl mit vertikaler Verbindungslinie, farbigen Typ-Badges (Note/Call/Email/Meeting/Task/FollowUp) - DashboardPage: Profil-Bereich (Theme-Schalter, Avatar, Name, Logout) in Tab-Leiste integriert und leicht farblich abgesetzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
812 lines
31 KiB
TypeScript
812 lines
31 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { useLocation, useNavigate } from 'react-router-dom';
|
||
import { useAuth } from '../auth/AuthContext';
|
||
import { UserAvatar } from '../components/UserAvatar';
|
||
import { useTheme } from '../theme/ThemeContext';
|
||
import { WeatherWidget } from '../components/WeatherWidget';
|
||
import { AnalogClock } from '../components/AnalogClock';
|
||
import { DashboardEmailTab } from './DashboardEmailTab';
|
||
import { DashboardCalendarTab, DayAgenda } from './DashboardCalendarTab';
|
||
import { DashboardTasksTab } from './DashboardTasksTab';
|
||
import { DashboardContactsTab } from './DashboardContactsTab';
|
||
import {
|
||
useIntegrations,
|
||
useOffice365CalendarRange,
|
||
useActiveTradeEvents,
|
||
useOffice365Emails,
|
||
useOffice365TasksFlat,
|
||
useCrmOpenTasks,
|
||
useCompleteO365Task,
|
||
useCompleteCrmTask,
|
||
} from '../crm/hooks';
|
||
import { useEventCountdown } from '../hooks/useEventCountdown';
|
||
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' },
|
||
// Weitere 30 Sprüche
|
||
{ text: 'Das Leben ist zu kurz, um schlechten Wein zu trinken.', author: 'Johann Wolfgang von Goethe' },
|
||
{ text: 'Zwei Dinge sind unendlich: das Universum und die menschliche Dummheit. Beim Universum bin ich mir nicht ganz sicher.', author: 'Albert Einstein' },
|
||
{ text: 'Wer mit Ungeheuern kämpft, mag zusehen, dass er nicht dabei zum Ungeheuer wird.', author: 'Friedrich Nietzsche' },
|
||
{ text: 'Die Kunst des Lebens liegt darin, den richtigen Augenblick zu erkennen.', author: 'Unbekannt' },
|
||
{ text: 'Tue jeden Tag etwas, das dich ein kleines Stück voranbringt.', author: 'Unbekannt' },
|
||
{ text: 'Es ist leichter, einen Fehler zu gestehen als ihn zu rechtfertigen.', author: 'Unbekannt' },
|
||
{ text: 'Wer andere kennt, ist klug. Wer sich selbst kennt, ist weise.', author: 'Laotse' },
|
||
{ text: 'Wer immer tut, was er schon kann, bleibt immer das, was er schon ist.', author: 'Henry Ford' },
|
||
{ text: 'Eine Reise von tausend Meilen beginnt mit einem einzigen Schritt.', author: 'Laotse' },
|
||
{ text: 'Niemand kann dir das Gefühl geben, minderwertig zu sein, ohne deine Zustimmung.', author: 'Eleanor Roosevelt' },
|
||
{ text: 'In jeder Schwierigkeit steckt eine Möglichkeit.', author: 'Albert Einstein' },
|
||
{ text: 'Charakter zeigt sich nicht in Ausnahmesituationen des Lebens, sondern in seiner Alltäglichkeit.', author: 'Aristoteles' },
|
||
{ text: 'Rede nicht darüber, was du tun wirst. Tu es.', author: 'Unbekannt' },
|
||
{ text: 'Die größte Schwäche liegt darin aufzugeben. Der sicherste Weg zum Erfolg ist, es noch einmal zu versuchen.', author: 'Thomas Edison' },
|
||
{ text: 'Gib jedem Tag die Chance, der schönste deines Lebens zu werden.', author: 'Mark Twain' },
|
||
{ text: 'Wenn der Schüler bereit ist, erscheint der Lehrer.', author: 'Buddhistisches Sprichwort' },
|
||
{ text: 'Der einzige Weg, gute Arbeit zu leisten, ist zu lieben, was man tut.', author: 'Steve Jobs' },
|
||
{ text: 'Träume nicht dein Leben, sondern lebe deinen Traum.', author: 'Unbekannt' },
|
||
{ text: 'Das Leben ist das, was passiert, während du eifrig dabei bist, andere Pläne zu machen.', author: 'John Lennon' },
|
||
{ text: 'Manchmal muss man einen Schritt zurückgehen, um zwei nach vorne machen zu können.', author: 'Unbekannt' },
|
||
{ text: 'Das Herz sieht schärfer als das Auge.', author: 'Russisches Sprichwort' },
|
||
{ text: 'Was du nicht willst, das man dir tu, das füg auch keinem andern zu.', author: 'Goldene Regel' },
|
||
{ text: 'Der Erfolg hat viele Väter, aber die Niederlage ist eine Waise.', author: 'John F. Kennedy' },
|
||
{ text: 'Qualität ist keine Handlung, sie ist eine Gewohnheit.', author: 'Aristoteles' },
|
||
{ text: 'Das Ziel des Lebens ist Selbstentfaltung.', author: 'Oscar Wilde' },
|
||
{ text: 'Die Natur eilt nie und bringt doch alles zustande.', author: 'Laotse' },
|
||
{ text: 'Wir sind, was wir wiederholt tun. Herausragend sein ist keine Handlung, sondern eine Gewohnheit.', author: 'Aristoteles' },
|
||
{ text: 'Mut ist nicht die Abwesenheit von Angst, sondern die Überzeugung, dass es etwas Wichtigeres gibt als Angst.', author: 'Nelson Mandela' },
|
||
{ text: 'Vergiss nicht: Du bist der Architekt deines eigenen Schicksals.', author: 'Alfred A. Montapert' },
|
||
{ text: 'Jeder, der aufgehört hat zu lernen, ist alt geworden — ob mit zwanzig oder mit achtzig.', author: 'Henry Ford' },
|
||
];
|
||
|
||
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 {
|
||
const endDay = new Date(event.endDate);
|
||
endDay.setHours(23, 59, 59, 999);
|
||
const cutoff = new Date(endDay.getTime() + 24 * 60 * 60 * 1000);
|
||
return new Date() < cutoff;
|
||
}
|
||
|
||
function formatDateRange(start: string, end: string): string {
|
||
const opts: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit', year: 'numeric' };
|
||
return `${new Date(start).toLocaleDateString('de-DE', opts)} – ${new Date(end).toLocaleDateString('de-DE', opts)}`;
|
||
}
|
||
|
||
// ── Kompakte Messe-Zeile (klickbar) ──────────────────────────────────────────
|
||
|
||
function CompactMesseRow({
|
||
event,
|
||
onClick,
|
||
}: {
|
||
event: TradeEvent;
|
||
onClick: () => void;
|
||
}) {
|
||
const countdown = useEventCountdown(event);
|
||
const color =
|
||
countdown.status === 'upcoming'
|
||
? '#3b82f6'
|
||
: countdown.status === 'ongoing'
|
||
? '#22c55e'
|
||
: '#9ca3af';
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={styles.messeRow}
|
||
style={{ borderLeftColor: color }}
|
||
onClick={onClick}
|
||
>
|
||
<span className={styles.messeRowName}>{event.name}</span>
|
||
<span className={styles.messeRowCountdown}>{countdown.label}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ── Messe-Detail-Popup ────────────────────────────────────────────────────────
|
||
|
||
function MesseDetailModal({
|
||
event,
|
||
onClose,
|
||
}: {
|
||
event: TradeEvent;
|
||
onClose: () => void;
|
||
}) {
|
||
const countdown = useEventCountdown(event);
|
||
const color =
|
||
countdown.status === 'upcoming'
|
||
? '#3b82f6'
|
||
: countdown.status === 'ongoing'
|
||
? '#22c55e'
|
||
: '#9ca3af';
|
||
|
||
return (
|
||
<div className={styles.messeModalBackdrop} onClick={onClose}>
|
||
<div
|
||
className={styles.messeModal}
|
||
style={{ borderTopColor: color }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Header */}
|
||
<div className={styles.messeModalHeader}>
|
||
<div>
|
||
<h3 className={styles.messeModalTitle}>{event.name}</h3>
|
||
<span
|
||
className={`${styles.messeStatusChip} ${styles[`messeChip_${countdown.status}`]}`}
|
||
>
|
||
{countdown.status === 'upcoming'
|
||
? 'Bevorstehend'
|
||
: countdown.status === 'ongoing'
|
||
? 'Läuft'
|
||
: 'Beendet'}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={styles.messeModalClose}
|
||
onClick={onClose}
|
||
aria-label="Schließen"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{/* Countdown */}
|
||
<div className={styles.messeModalCountdown}>{countdown.label}</div>
|
||
{countdown.status === 'ongoing' && (
|
||
<div className={styles.messeProgressBar}>
|
||
<div
|
||
className={styles.messeProgressFill}
|
||
style={{ width: `${countdown.progressPercent}%`, background: color }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Daten */}
|
||
<div className={styles.messeModalMeta}>
|
||
<div className={styles.messeModalMetaRow}>
|
||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||
<rect x="2" y="3" width="12" height="11" rx="1" /><path d="M5 1v4M11 1v4M2 7h12" />
|
||
</svg>
|
||
<span>{formatDateRange(event.startDate, event.endDate)}</span>
|
||
</div>
|
||
|
||
{event.location && (
|
||
<div className={styles.messeModalMetaRow}>
|
||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M8 1C5.2 1 3 3.2 3 6c0 4 5 9 5 9s5-5 5-9c0-2.8-2.2-5-5-5z" /><circle cx="8" cy="6" r="1.5" />
|
||
</svg>
|
||
<span>{event.location}{event.boothInfo ? ` · Stand: ${event.boothInfo}` : ''}</span>
|
||
</div>
|
||
)}
|
||
|
||
{!event.location && event.boothInfo && (
|
||
<div className={styles.messeModalMetaRow}>
|
||
<span>Stand: {event.boothInfo}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{event.description && (
|
||
<p className={styles.messeModalDesc}>{event.description}</p>
|
||
)}
|
||
|
||
{event.websiteUrl && (
|
||
<a
|
||
href={event.websiteUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className={styles.messeModalLink}
|
||
>
|
||
Website öffnen ↗
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Kompakter Messe-Ticker für Sidebar ───────────────────────────────────────
|
||
|
||
function CompactMesseTicker() {
|
||
const { data } = useActiveTradeEvents();
|
||
const [selected, setSelected] = useState<TradeEvent | null>(null);
|
||
const events = (data?.data ?? []).filter(isStillRelevant);
|
||
|
||
if (events.length === 0) return null;
|
||
|
||
return (
|
||
<div className={styles.compactMesse}>
|
||
<h4 className={styles.compactMesseTitle}>Messen</h4>
|
||
<div className={styles.compactMesseList}>
|
||
{events.map((ev) => (
|
||
<CompactMesseRow key={ev.id} event={ev} onClick={() => setSelected(ev)} />
|
||
))}
|
||
</div>
|
||
{selected && (
|
||
<MesseDetailModal event={selected} onClose={() => setSelected(null)} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Linke Spalte: Uhr + Wetter + 3-Tage-Prognose ─────────────────────────────
|
||
|
||
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 ─────────────────────────────────────
|
||
|
||
function HomeSidebar() {
|
||
const today = new Date();
|
||
const todayISO = today.toISOString().slice(0, 10);
|
||
const tomorrowISO = new Date(today.getTime() + 86_400_000).toISOString().slice(0, 10);
|
||
|
||
const { data: integrationsData } = useIntegrations();
|
||
const isConnected =
|
||
integrationsData?.data?.some(
|
||
(i) => i.provider === 'MICROSOFT_365' && i.connected,
|
||
) ?? false;
|
||
|
||
const { data: eventsData, isLoading } = useOffice365CalendarRange(todayISO, tomorrowISO);
|
||
const events: M365CalendarEvent[] = eventsData?.data ?? [];
|
||
|
||
return (
|
||
<div className={styles.homeSidebar}>
|
||
<CompactMesseTicker />
|
||
|
||
{isConnected && (
|
||
isLoading
|
||
? <p style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)', marginTop: '0.5rem' }}>
|
||
Kalender wird geladen…
|
||
</p>
|
||
: <DayAgenda day={today} events={events} fullWidth />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Tab-Definitionen ──────────────────────────────────────────────────────────
|
||
|
||
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;
|
||
onSwitchTab: (tab: DashboardTab) => void;
|
||
}) {
|
||
return (
|
||
<>
|
||
{/* Header: Name links, Spruch rechts */}
|
||
<div className={styles.header}>
|
||
<h1 className={styles.title}>
|
||
Willkommen, {firstName} {lastName}
|
||
</h1>
|
||
<QuoteOfTheDay />
|
||
</div>
|
||
|
||
{/* 3-Spalten-Layout */}
|
||
<div className={styles.homeLayout}>
|
||
{/* Links: Uhr + Wetter + Prognose */}
|
||
<HomeLeftColumn city={city} />
|
||
|
||
{/* Mitte: Aufgaben + E-Mails */}
|
||
<div className={styles.homeMain}>
|
||
<HomeTasksWidget onSeeAll={() => onSwitchTab('tasks')} />
|
||
<HomeEmailsWidget onSeeAll={() => onSwitchTab('emails')} />
|
||
</div>
|
||
|
||
{/* Rechts: Messe-Ticker + Tagesagenda */}
|
||
<HomeSidebar />
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||
|
||
const THEME_OPTIONS_DASH = [
|
||
{ value: 'light' as const, icon: '☀' },
|
||
{ value: 'dark' as const, icon: '☾' },
|
||
{ value: 'system' as const, icon: '⚙' },
|
||
];
|
||
|
||
export function DashboardPage() {
|
||
const { user, logout } = useAuth();
|
||
const location = useLocation();
|
||
const navigate = useNavigate();
|
||
const { mode, setMode } = useTheme();
|
||
const [activeTab, setActiveTab] = useState<DashboardTab>('home');
|
||
|
||
const handleLogout = async () => {
|
||
await logout();
|
||
navigate('/login');
|
||
};
|
||
|
||
// Immer auf Home-Tab springen wenn Dashboard-NavLink geklickt wird
|
||
useEffect(() => {
|
||
setActiveTab('home');
|
||
}, [location.key]);
|
||
|
||
return (
|
||
<div>
|
||
{/* Tab-Leiste */}
|
||
<div className={styles.tabBar}>
|
||
{TABS.map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
className={`${styles.tab} ${activeTab === tab.id ? styles.activeTab : ''}`}
|
||
onClick={() => setActiveTab(tab.id)}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
|
||
{/* Profil-Bereich rechts in der Tab-Leiste */}
|
||
<div className={styles.tabBarProfile}>
|
||
{/* Theme-Schalter */}
|
||
<div className={styles.tabBarThemeGroup}>
|
||
{THEME_OPTIONS_DASH.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
className={`${styles.tabBarThemeBtn}${mode === opt.value ? ` ${styles.tabBarThemeBtnActive}` : ''}`}
|
||
onClick={() => setMode(opt.value)}
|
||
title={opt.value === 'light' ? 'Hell' : opt.value === 'dark' ? 'Dunkel' : 'System'}
|
||
>
|
||
{opt.icon}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Benutzer (→ Profil) */}
|
||
<div
|
||
className={styles.tabBarUser}
|
||
onClick={() => navigate('/profile')}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') navigate('/profile');
|
||
}}
|
||
title="Profil bearbeiten"
|
||
>
|
||
<UserAvatar
|
||
firstName={user?.firstName ?? ''}
|
||
lastName={user?.lastName ?? ''}
|
||
avatar={user?.avatar}
|
||
size={24}
|
||
/>
|
||
<span className={styles.tabBarUserName}>
|
||
{user?.firstName} {user?.lastName}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Abmelden */}
|
||
<button
|
||
type="button"
|
||
className={styles.tabBarLogout}
|
||
onClick={handleLogout}
|
||
title="Abmelden"
|
||
>
|
||
<svg
|
||
width="13"
|
||
height="13"
|
||
viewBox="0 0 16 16"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<path d="M10 3h3a1 1 0 011 1v8a1 1 0 01-1 1h-3" />
|
||
<path d="M7 11l3-3-3-3" />
|
||
<path d="M10 8H3" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tab-Inhalt */}
|
||
<div className={styles.tabContent}>
|
||
{activeTab === 'home' && (
|
||
<HomeTab
|
||
firstName={user?.firstName}
|
||
lastName={user?.lastName}
|
||
city={user?.city}
|
||
onSwitchTab={setActiveTab}
|
||
/>
|
||
)}
|
||
{activeTab === 'emails' && <DashboardEmailTab />}
|
||
{activeTab === 'calendar' && <DashboardCalendarTab />}
|
||
{activeTab === 'tasks' && <DashboardTasksTab />}
|
||
{activeTab === 'contacts' && <DashboardContactsTab />}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|