mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 22:36:38 +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;
|
||||
is_day: number;
|
||||
};
|
||||
daily: {
|
||||
time: string[];
|
||||
weather_code: number[];
|
||||
temperature_2m_max: number[];
|
||||
temperature_2m_min: number[];
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public Types
|
||||
// ============================================================
|
||||
|
||||
export interface ForecastDay {
|
||||
date: string; // ISO yyyy-mm-dd
|
||||
weatherCode: number;
|
||||
tempMax: number;
|
||||
tempMin: number;
|
||||
icon: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface WeatherData {
|
||||
temperature: number;
|
||||
weatherCode: number;
|
||||
|
|
@ -32,6 +47,7 @@ export interface WeatherData {
|
|||
cityName: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
forecast: ForecastDay[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -87,11 +103,16 @@ export function useWeather(city: string | null | undefined) {
|
|||
|
||||
const geoResult = geocoding.data?.results?.[0];
|
||||
|
||||
// Schritt 2: Koordinaten -> Aktuelles Wetter
|
||||
// Schritt 2: Koordinaten -> Aktuelles Wetter + 3-Tage-Prognose
|
||||
const weather = useQuery<WeatherResponse>({
|
||||
queryKey: ['weather', geoResult?.latitude, geoResult?.longitude],
|
||||
queryFn: async () => {
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${geoResult!.latitude}&longitude=${geoResult!.longitude}¤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<WeatherResponse>;
|
||||
|
|
@ -107,6 +128,23 @@ export function useWeather(city: string | null | undefined) {
|
|||
? (() => {
|
||||
const { temperature_2m, weather_code, is_day } = weather.data.current;
|
||||
const info = getWeatherInfo(weather_code, is_day === 1);
|
||||
|
||||
// 3-Tage-Prognose
|
||||
const forecast: ForecastDay[] = (weather.data.daily?.time ?? []).map(
|
||||
(date, i) => {
|
||||
const code = weather.data!.daily.weather_code[i] ?? 0;
|
||||
const fi = getWeatherInfo(code, true); // Tagessymbole für Prognose
|
||||
return {
|
||||
date,
|
||||
weatherCode: code,
|
||||
tempMax: Math.round(weather.data!.daily.temperature_2m_max[i] ?? 0),
|
||||
tempMin: Math.round(weather.data!.daily.temperature_2m_min[i] ?? 0),
|
||||
icon: fi.icon,
|
||||
label: fi.label,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
temperature: temperature_2m,
|
||||
weatherCode: weather_code,
|
||||
|
|
@ -114,6 +152,7 @@ export function useWeather(city: string | null | undefined) {
|
|||
cityName: geoResult.name,
|
||||
icon: info.icon,
|
||||
label: info.label,
|
||||
forecast,
|
||||
};
|
||||
})()
|
||||
: undefined;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,81 @@
|
|||
import { useState } from 'react';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { WeatherWidget } from '../components/WeatherWidget';
|
||||
import { AnalogClock } from '../components/AnalogClock';
|
||||
import { DashboardEmailTab } from './DashboardEmailTab';
|
||||
import { DashboardCalendarTab, DayAgenda } from './DashboardCalendarTab';
|
||||
import { DashboardTasksTab } from './DashboardTasksTab';
|
||||
import { useIntegrations, useOffice365CalendarRange, useActiveTradeEvents } from '../crm/hooks';
|
||||
import {
|
||||
useIntegrations,
|
||||
useOffice365CalendarRange,
|
||||
useActiveTradeEvents,
|
||||
useOffice365Emails,
|
||||
useOffice365TasksFlat,
|
||||
useCrmOpenTasks,
|
||||
useCompleteO365Task,
|
||||
useCompleteCrmTask,
|
||||
} from '../crm/hooks';
|
||||
import { useEventCountdown } from '../hooks/useEventCountdown';
|
||||
import type { M365CalendarEvent, TradeEvent } from '../crm/types';
|
||||
import { useWeather } from '../hooks/useWeather';
|
||||
import type { M365CalendarEvent, TradeEvent, M365Email } from '../crm/types';
|
||||
import type { M365TaskFlat, CrmOpenTask } from '../crm/types';
|
||||
import styles from './DashboardPage.module.css';
|
||||
|
||||
// ── Spruch des Tages ──────────────────────────────────────────────────────────
|
||||
|
||||
const QUOTES: { text: string; author: string }[] = [
|
||||
{ text: 'Der Weg ist das Ziel.', author: 'Konfuzius' },
|
||||
{ text: 'Erfolg hat drei Buchstaben: Tun.', author: 'J. W. von Goethe' },
|
||||
{ text: 'Wer aufhört, besser zu werden, hat aufgehört, gut zu sein.', author: 'Philip Rosenthal' },
|
||||
{ text: 'Die Fantasie ist wichtiger als das Wissen.', author: 'Albert Einstein' },
|
||||
{ text: 'Wer kämpft, kann verlieren. Wer nicht kämpft, hat schon verloren.', author: 'Bertolt Brecht' },
|
||||
{ text: 'Sei du selbst die Veränderung, die du dir wünschst für diese Welt.', author: 'Mahatma Gandhi' },
|
||||
{ text: 'Wenn der Wind der Veränderung weht, bauen die einen Mauern und die anderen Windmühlen.', author: 'Chinesisches Sprichwort' },
|
||||
{ text: 'Das Geheimnis des Erfolgs ist, den Standpunkt des anderen zu verstehen.', author: 'Henry Ford' },
|
||||
{ text: 'Tu erst das Notwendige, dann das Mögliche, und plötzlich schaffst du das Unmögliche.', author: 'Franz von Assisi' },
|
||||
{ text: 'Man sieht nur mit dem Herzen gut. Das Wesentliche ist für die Augen unsichtbar.', author: 'Antoine de Saint-Exupéry' },
|
||||
{ text: 'Nicht die Stärksten überleben, sondern die Anpassungsfähigsten.', author: 'Charles Darwin' },
|
||||
{ text: 'Ein langer Marsch beginnt mit dem ersten Schritt.', author: 'Laotse' },
|
||||
{ text: 'Wer wagt, gewinnt.', author: 'Volksweisheit' },
|
||||
{ text: 'Jeder Experte war einmal ein Anfänger.', author: 'Unbekannt' },
|
||||
{ text: 'In der Mitte liegt die Kraft.', author: 'Sprichwort' },
|
||||
{ text: 'Nicht wer wenig hat, sondern wer viel begehrt, ist arm.', author: 'Seneca' },
|
||||
{ text: 'Kreativität ist Intelligenz, die Spaß hat.', author: 'Albert Einstein' },
|
||||
{ text: 'Stärke zeigt sich nicht darin, niemals zu fallen, sondern darin, nach jedem Fall aufzustehen.', author: 'Nelson Mandela' },
|
||||
{ text: 'Der beste Zeitpunkt, einen Baum zu pflanzen, war vor zwanzig Jahren. Der zweitbeste ist jetzt.', author: 'Chinesisches Sprichwort' },
|
||||
{ text: 'Übung macht den Meister.', author: 'Volksweisheit' },
|
||||
{ text: 'Es ist nicht genug zu wissen — man muss auch anwenden.', author: 'J. W. von Goethe' },
|
||||
{ text: 'Optimismus ist die Grundlage des Mutes.', author: 'Nick Butler' },
|
||||
{ text: 'Wo ein Wille ist, ist auch ein Weg.', author: 'Volksweisheit' },
|
||||
{ text: 'Die größte Entdeckungsreise liegt nicht darin, neue Länder zu suchen, sondern darin, die Dinge mit neuen Augen zu sehen.', author: 'Marcel Proust' },
|
||||
{ text: 'Der Klügere gibt nach — eine traurige Wahrheit, sie begründet die Weltherrschaft der Dummheit.', author: 'Marie von Ebner-Eschenbach' },
|
||||
{ text: 'Heute ist der erste Tag vom Rest deines Lebens.', author: 'Sprichwort' },
|
||||
{ text: 'Ein gutes Gewissen ist ein sanftes Ruhekissen.', author: 'Volksweisheit' },
|
||||
{ text: 'Kein Mensch hat das Recht, das zu unterlassen, was er kann.', author: 'Albert Schweitzer' },
|
||||
{ text: 'Wissen ist Macht.', author: 'Francis Bacon' },
|
||||
{ text: 'Lächle und die Welt lächelt zurück.', author: 'Volksweisheit' },
|
||||
{ text: 'Der Mut macht erst den Mann.', author: 'Friedrich Schiller' },
|
||||
{ text: 'Ein Lächeln kostet nichts, bringt aber viel.', author: 'Unbekannt' },
|
||||
{ text: 'Aus Fehlern lernt man — außer man macht sie nicht.', author: 'Sprichwort' },
|
||||
{ text: 'Denke nicht daran, was du tun könntest, sondern was du tust.', author: 'Unbekannt' },
|
||||
{ text: 'Die Zeit heilt alle Wunden.', author: 'Volksweisheit' },
|
||||
];
|
||||
|
||||
function getDayOfYear(date: Date): number {
|
||||
const start = new Date(date.getFullYear(), 0, 0);
|
||||
return Math.floor((date.getTime() - start.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function QuoteOfTheDay() {
|
||||
const q = QUOTES[getDayOfYear(new Date()) % QUOTES.length];
|
||||
return (
|
||||
<div className={styles.quoteOfDay}>
|
||||
<span className={styles.quoteText}>„{q.text}"</span>
|
||||
<span className={styles.quoteAuthor}>— {q.author}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Messe-Ticker: Hilfsfunktionen ─────────────────────────────────────────────
|
||||
|
||||
function isStillRelevant(event: TradeEvent): boolean {
|
||||
|
|
@ -180,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 (
|
||||
<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 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 (
|
||||
<div className={styles.homeSidebar}>
|
||||
{/* Kompakter Messe-Ticker immer oben */}
|
||||
<CompactMesseTicker />
|
||||
|
||||
{/* Tages-Agenda nur wenn O365 verbunden */}
|
||||
{isConnected && (
|
||||
isLoading
|
||||
? <p style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)', marginTop: '0.5rem' }}>
|
||||
|
|
@ -223,33 +600,53 @@ function HomeSidebar() {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Tab-Inhalte ───────────────────────────────────────────────────────────────
|
||||
// ── Tab-Definitionen ──────────────────────────────────────────────────────────
|
||||
|
||||
function HomeTab({ firstName, lastName, city, role }: {
|
||||
type DashboardTab = 'home' | 'emails' | 'calendar' | 'tasks' | 'contacts';
|
||||
|
||||
const TABS: { id: DashboardTab; label: string }[] = [
|
||||
{ id: 'home', label: 'Home' },
|
||||
{ id: 'emails', label: 'E-Mail' },
|
||||
{ id: 'calendar', label: 'Kalender' },
|
||||
{ id: 'tasks', label: 'Aufgaben' },
|
||||
{ id: 'contacts', label: 'Kontakte' },
|
||||
];
|
||||
|
||||
// ── HomeTab ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function HomeTab({
|
||||
firstName,
|
||||
lastName,
|
||||
city,
|
||||
onSwitchTab,
|
||||
}: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
city?: string | null;
|
||||
role?: string;
|
||||
onSwitchTab: (tab: DashboardTab) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Header: Name links, Spruch rechts */}
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.title}>
|
||||
Willkommen, {firstName} {lastName}
|
||||
</h1>
|
||||
<WeatherWidget city={city ?? undefined} />
|
||||
<QuoteOfTheDay />
|
||||
</div>
|
||||
|
||||
{/* 3-Spalten-Layout */}
|
||||
<div className={styles.homeLayout}>
|
||||
{/* Links: Uhr + Wetter + Prognose */}
|
||||
<HomeLeftColumn city={city} />
|
||||
|
||||
{/* Mitte: Aufgaben + E-Mails */}
|
||||
<div className={styles.homeMain}>
|
||||
<div className={styles.placeholder}>
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>
|
||||
INSIGHT Platform - Sprint 1 Alpha
|
||||
</p>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', marginTop: '0.5rem' }}>
|
||||
Rolle: {role}
|
||||
</p>
|
||||
</div>
|
||||
<HomeTasksWidget onSeeAll={() => onSwitchTab('tasks')} />
|
||||
<HomeEmailsWidget onSeeAll={() => onSwitchTab('emails')} />
|
||||
</div>
|
||||
|
||||
{/* Rechts: Messe-Ticker + Tagesagenda */}
|
||||
<HomeSidebar />
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -295,7 +692,7 @@ export function DashboardPage() {
|
|||
firstName={user?.firstName}
|
||||
lastName={user?.lastName}
|
||||
city={user?.city}
|
||||
role={user?.role}
|
||||
onSwitchTab={setActiveTab}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'emails' && <DashboardEmailTab />}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue