mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
feat(frontend+core): Dashboard Kontakte-Tab (O365) + Admin Logo-/Sidebar-Breite
Dashboard Kontakte-Tab: - DashboardContactsTab.tsx + CSS: O365-Kontakte als Visitenkarten oder Liste - Kachelansicht (auto-fill Grid) + Listenansicht (6-spaltig) umschaltbar - Suchfeld (Name, E-Mail, Firma) mit Live-Filter - Klick öffnet Detail-Modal mit allen Kontaktdaten (E-Mail/Telefon als Links) - CRM-Import-Button: mappt M365Contact → CreateContactPayload, importiert direkt - Nicht verbunden / Laden / Fehler States - DashboardPage: ComingSoonTab entfernt, DashboardContactsTab eingebunden Admin Branding — Logo-Breite + Sidebar-Breite: - settings.controller.ts: logoWidth + sidebarWidth in GET/POST Branding - AdminCustomizePage: Slider Logo-Breite (40–240px) + Sidebar-Breite (200–360px) mit Live-Vorschau (skalierte Mini-Sidebar) - AppLayout: Logo-maxWidth aus branding.logoWidth; Sidebar width + main marginLeft dynamisch aus branding.sidebarWidth Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d9c6240a3e
commit
3d75a7f9de
6 changed files with 1031 additions and 25 deletions
|
|
@ -145,19 +145,23 @@ export class SettingsController {
|
||||||
async getBranding(): Promise<{
|
async getBranding(): Promise<{
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
sidebarColor: string | null;
|
sidebarColor: string | null;
|
||||||
|
logoWidth: number | null;
|
||||||
|
sidebarWidth: number | null;
|
||||||
}> {
|
}> {
|
||||||
const raw = await this.redis.get(BRANDING_LOGO_KEY);
|
const raw = await this.redis.get(BRANDING_LOGO_KEY);
|
||||||
if (!raw) return { logo: null, sidebarColor: null };
|
if (!raw) return { logo: null, sidebarColor: null, logoWidth: null, sidebarWidth: null };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(raw);
|
const data = JSON.parse(raw);
|
||||||
return {
|
return {
|
||||||
logo: data.logo || null,
|
logo: data.logo || null,
|
||||||
sidebarColor: data.sidebarColor || null,
|
sidebarColor: data.sidebarColor || null,
|
||||||
|
logoWidth: typeof data.logoWidth === 'number' ? data.logoWidth : null,
|
||||||
|
sidebarWidth: typeof data.sidebarWidth === 'number' ? data.sidebarWidth : null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// Legacy: nur Logo als String
|
// Legacy: nur Logo als String
|
||||||
return { logo: raw, sidebarColor: null };
|
return { logo: raw, sidebarColor: null, logoWidth: null, sidebarWidth: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,15 +174,30 @@ export class SettingsController {
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@ApiOperation({ summary: 'Branding-Einstellungen speichern (Admin)' })
|
@ApiOperation({ summary: 'Branding-Einstellungen speichern (Admin)' })
|
||||||
async saveBranding(
|
async saveBranding(
|
||||||
@Body() body: { logo?: string | null; sidebarColor?: string | null },
|
@Body() body: {
|
||||||
|
logo?: string | null;
|
||||||
|
sidebarColor?: string | null;
|
||||||
|
logoWidth?: number | null;
|
||||||
|
sidebarWidth?: number | null;
|
||||||
|
},
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
if (body.logo && body.logo.length > 500_000) {
|
if (body.logo && body.logo.length > 500_000) {
|
||||||
throw new BadRequestException('Logo darf maximal 500KB gross sein');
|
throw new BadRequestException('Logo darf maximal 500KB gross sein');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Werte-Grenzen
|
||||||
|
const logoWidth = typeof body.logoWidth === 'number'
|
||||||
|
? Math.min(Math.max(Math.round(body.logoWidth), 40), 240)
|
||||||
|
: null;
|
||||||
|
const sidebarWidth = typeof body.sidebarWidth === 'number'
|
||||||
|
? Math.min(Math.max(Math.round(body.sidebarWidth), 200), 360)
|
||||||
|
: null;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
logo: body.logo || null,
|
logo: body.logo || null,
|
||||||
sidebarColor: body.sidebarColor || null,
|
sidebarColor: body.sidebarColor || null,
|
||||||
|
logoWidth,
|
||||||
|
sidebarWidth,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.redis.set(BRANDING_LOGO_KEY, JSON.stringify(data));
|
await this.redis.set(BRANDING_LOGO_KEY, JSON.stringify(data));
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import api from '../api/client';
|
||||||
interface BrandingData {
|
interface BrandingData {
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
sidebarColor: string | null;
|
sidebarColor: string | null;
|
||||||
|
logoWidth: number | null;
|
||||||
|
sidebarWidth: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIDEBAR_PRESETS = [
|
const SIDEBAR_PRESETS = [
|
||||||
|
|
@ -63,6 +65,8 @@ export function AdminCustomizePage() {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [logo, setLogo] = useState<string | null>(null);
|
const [logo, setLogo] = useState<string | null>(null);
|
||||||
const [sidebarColor, setSidebarColor] = useState<string>('#1e293b');
|
const [sidebarColor, setSidebarColor] = useState<string>('#1e293b');
|
||||||
|
const [logoWidth, setLogoWidth] = useState<number>(160);
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState<number>(240);
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
|
||||||
|
|
@ -78,6 +82,8 @@ export function AdminCustomizePage() {
|
||||||
if (data) {
|
if (data) {
|
||||||
setLogo(data.logo);
|
setLogo(data.logo);
|
||||||
setSidebarColor(data.sidebarColor || '#1e293b');
|
setSidebarColor(data.sidebarColor || '#1e293b');
|
||||||
|
setLogoWidth(data.logoWidth ?? 160);
|
||||||
|
setSidebarWidth(data.sidebarWidth ?? 240);
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
@ -86,6 +92,8 @@ export function AdminCustomizePage() {
|
||||||
mutationFn: async (branding: {
|
mutationFn: async (branding: {
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
sidebarColor: string;
|
sidebarColor: string;
|
||||||
|
logoWidth: number;
|
||||||
|
sidebarWidth: number;
|
||||||
}) => {
|
}) => {
|
||||||
const res = await api.post('/settings/branding', branding);
|
const res = await api.post('/settings/branding', branding);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|
@ -129,7 +137,7 @@ export function AdminCustomizePage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
saveMutation.mutate({ logo, sidebarColor });
|
saveMutation.mutate({ logo, sidebarColor, logoWidth, sidebarWidth });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -169,6 +177,7 @@ export function AdminCustomizePage() {
|
||||||
SVG mit transparentem Hintergrund, max. 500KB.
|
SVG mit transparentem Hintergrund, max. 500KB.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Vorschau + Upload */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -188,6 +197,7 @@ export function AdminCustomizePage() {
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
background: sidebarColor,
|
background: sidebarColor,
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{logo ? (
|
{logo ? (
|
||||||
|
|
@ -195,7 +205,7 @@ export function AdminCustomizePage() {
|
||||||
src={logo}
|
src={logo}
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: `${Math.round(logoWidth * 0.72)}px`,
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
}}
|
}}
|
||||||
|
|
@ -242,6 +252,29 @@ export function AdminCustomizePage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Logo-Breite Slider */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
Logo-Breite: <strong>{logoWidth}px</strong>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={40}
|
||||||
|
max={240}
|
||||||
|
step={10}
|
||||||
|
value={logoWidth}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLogoWidth(Number(e.target.value));
|
||||||
|
setHasChanges(true);
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', maxWidth: 320, display: 'block', marginBottom: '0.25rem' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', maxWidth: 320, fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
|
||||||
|
<span>40px</span>
|
||||||
|
<span>240px</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar-Farbe */}
|
{/* Sidebar-Farbe */}
|
||||||
|
|
@ -414,6 +447,70 @@ export function AdminCustomizePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar-Breite */}
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>
|
||||||
|
Sidebar-Breite
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginBottom: '1rem' }}>
|
||||||
|
Breite der linken Menü-Leiste im ausgeklappten Zustand.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Slider + Vorschau */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '2rem', flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ flex: '1 1 280px' }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
Breite: <strong>{sidebarWidth}px</strong>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={200}
|
||||||
|
max={360}
|
||||||
|
step={10}
|
||||||
|
value={sidebarWidth}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSidebarWidth(Number(e.target.value));
|
||||||
|
setHasChanges(true);
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', maxWidth: 320, display: 'block', marginBottom: '0.25rem' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', maxWidth: 320, fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
|
||||||
|
<span>200px (schmal)</span>
|
||||||
|
<span>360px (breit)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar-Vorschau skaliert */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: Math.round(sidebarWidth * 0.45),
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: sidebarColor,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: '0.5rem 0.625rem',
|
||||||
|
gap: '0.35rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'width 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Mini-Logo */}
|
||||||
|
<div style={{ fontSize: '0.5rem', color: '#60a5fa', fontWeight: 700, letterSpacing: 1, marginBottom: '0.25rem' }}>
|
||||||
|
{logo ? (
|
||||||
|
<img src={logo} alt="" style={{ maxWidth: Math.round(logoWidth * 0.3), maxHeight: 12, objectFit: 'contain' }} />
|
||||||
|
) : 'INSIGHT'}
|
||||||
|
</div>
|
||||||
|
{/* Mini Nav-Einträge */}
|
||||||
|
{[80, 65, 75, 55].map((w, i) => (
|
||||||
|
<div key={i} style={{ height: 5, width: `${w}%`, background: 'rgba(255,255,255,0.2)', borderRadius: 2 }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Speichern */}
|
{/* Speichern */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -150,12 +150,16 @@ export function AppLayout() {
|
||||||
const { data: branding } = useQuery<{
|
const { data: branding } = useQuery<{
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
sidebarColor: string | null;
|
sidebarColor: string | null;
|
||||||
|
logoWidth: number | null;
|
||||||
|
sidebarWidth: number | null;
|
||||||
}>({
|
}>({
|
||||||
queryKey: ['settings', 'branding'],
|
queryKey: ['settings', 'branding'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await api.get<{
|
const res = await api.get<{
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
sidebarColor: string | null;
|
sidebarColor: string | null;
|
||||||
|
logoWidth: number | null;
|
||||||
|
sidebarWidth: number | null;
|
||||||
}>('/settings/branding');
|
}>('/settings/branding');
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
@ -172,11 +176,10 @@ export function AppLayout() {
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside
|
<aside
|
||||||
className={`${styles.sidebar} ${collapsed ? styles.sidebarCollapsed : ''}`}
|
className={`${styles.sidebar} ${collapsed ? styles.sidebarCollapsed : ''}`}
|
||||||
style={
|
style={{
|
||||||
branding?.sidebarColor
|
...(branding?.sidebarColor ? { background: branding.sidebarColor } : {}),
|
||||||
? { background: branding.sidebarColor }
|
...(!collapsed && branding?.sidebarWidth ? { width: branding.sidebarWidth } : {}),
|
||||||
: undefined
|
}}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className={styles.brand}>
|
<div className={styles.brand}>
|
||||||
{!collapsed &&
|
{!collapsed &&
|
||||||
|
|
@ -186,7 +189,7 @@ export function AppLayout() {
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
style={{
|
style={{
|
||||||
maxHeight: 44,
|
maxHeight: 44,
|
||||||
maxWidth: 160,
|
maxWidth: branding.logoWidth ?? 160,
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -578,7 +581,7 @@ export function AppLayout() {
|
||||||
<main
|
<main
|
||||||
className={styles.main}
|
className={styles.main}
|
||||||
style={{
|
style={{
|
||||||
marginLeft: collapsed ? 60 : undefined,
|
marginLeft: collapsed ? 60 : (branding?.sidebarWidth ?? undefined),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Topbar: Profil + Logout + Modiwahl oben rechts */}
|
{/* Topbar: Profil + Logout + Modiwahl oben rechts */}
|
||||||
|
|
|
||||||
485
packages/frontend/src/shell/DashboardContactsTab.module.css
Normal file
485
packages/frontend/src/shell/DashboardContactsTab.module.css
Normal file
|
|
@ -0,0 +1,485 @@
|
||||||
|
/* ============================================================
|
||||||
|
DashboardContactsTab — O365-Kontakte mit Kachel-/Listenansicht
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.root {
|
||||||
|
/* Nutzt das Padding von .mainContent */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toolbar ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchWrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 400px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.625rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 2rem 0.5rem 2.25rem;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchClear {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
transition: color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchClear:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ansichts-Toggle */
|
||||||
|
.viewToggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewBtn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewBtn:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewBtnActive {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Kachelansicht ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(195px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:focus-visible {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardAvatar {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardName {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardJobTitle {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardCompany {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardEmail {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardPhone {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Listenansicht ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.listHeader {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 36px 1.5fr 1fr 1fr 1.5fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listHeaderCell {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 36px 1.5fr 1fr 1fr 1.5fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:focus-visible {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowAvatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowName {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowMeta {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Leerer Zustand ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status-Seiten (nicht verbunden, laden, Fehler) ──────────────────────────── */
|
||||||
|
|
||||||
|
.state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 5rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stateIcon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stateTitle {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stateSub {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stateCenter {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal ────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg, var(--radius-md));
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
position: relative;
|
||||||
|
padding: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalClose {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalClose:hover {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding-right: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalAvatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalName {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalJobTitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalCompany {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalDetails {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailLabel {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
min-width: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailValue {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailValue:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailEmpty {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalFooter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.importBtn {
|
||||||
|
padding: 0.625rem 1.5rem;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
min-width: 150px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.importBtn:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.importBtn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.importBtnSuccess {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.importBtnError {
|
||||||
|
background: #ef4444;
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
411
packages/frontend/src/shell/DashboardContactsTab.tsx
Normal file
411
packages/frontend/src/shell/DashboardContactsTab.tsx
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useOffice365Contacts, useIntegrations, useCreateContact } from '../crm/hooks';
|
||||||
|
import type { M365Contact, CreateContactPayload } from '../crm/types';
|
||||||
|
import styles from './DashboardContactsTab.module.css';
|
||||||
|
|
||||||
|
// ── Hilfsfunktionen ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return (
|
||||||
|
(parts[0][0] ?? '') + (parts[parts.length - 1][0] ?? '')
|
||||||
|
).toUpperCase();
|
||||||
|
}
|
||||||
|
return name.substring(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVATAR_COLORS = [
|
||||||
|
'#6366f1', '#8b5cf6', '#ec4899', '#f43f5e',
|
||||||
|
'#f97316', '#eab308', '#22c55e', '#14b8a6',
|
||||||
|
'#3b82f6', '#0ea5e9',
|
||||||
|
];
|
||||||
|
|
||||||
|
function getAvatarColor(name: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kontakt-Kachel ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ContactCard({
|
||||||
|
contact,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
contact: M365Contact;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const initials = getInitials(contact.displayName);
|
||||||
|
const color = getAvatarColor(contact.displayName);
|
||||||
|
const email = contact.emailAddresses[0]?.address ?? null;
|
||||||
|
const phone = contact.mobilePhone ?? contact.businessPhones[0] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.card}
|
||||||
|
onClick={onClick}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') onClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.cardAvatar} style={{ background: color }}>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className={styles.cardName}>{contact.displayName}</div>
|
||||||
|
{contact.jobTitle && <div className={styles.cardJobTitle}>{contact.jobTitle}</div>}
|
||||||
|
{contact.companyName && <div className={styles.cardCompany}>{contact.companyName}</div>}
|
||||||
|
{email && <div className={styles.cardEmail}>{email}</div>}
|
||||||
|
{phone && <div className={styles.cardPhone}>{phone}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kontakt-Zeile ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ContactRow({
|
||||||
|
contact,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
contact: M365Contact;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const initials = getInitials(contact.displayName);
|
||||||
|
const color = getAvatarColor(contact.displayName);
|
||||||
|
const email = contact.emailAddresses[0]?.address ?? null;
|
||||||
|
const phone = contact.mobilePhone ?? contact.businessPhones[0] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.row}
|
||||||
|
onClick={onClick}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') onClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.rowAvatar} style={{ background: color }}>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className={styles.rowName}>{contact.displayName}</div>
|
||||||
|
<div className={styles.rowMeta}>{contact.companyName ?? '—'}</div>
|
||||||
|
<div className={styles.rowMeta}>{contact.jobTitle ?? '—'}</div>
|
||||||
|
<div className={styles.rowMeta}>{email ?? '—'}</div>
|
||||||
|
<div className={styles.rowMeta}>{phone ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detail-Modal ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ImportStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
function ContactModal({
|
||||||
|
contact,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
contact: M365Contact;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const createContact = useCreateContact();
|
||||||
|
const [importStatus, setImportStatus] = useState<ImportStatus>('idle');
|
||||||
|
|
||||||
|
const initials = getInitials(contact.displayName);
|
||||||
|
const color = getAvatarColor(contact.displayName);
|
||||||
|
const email = contact.emailAddresses[0]?.address ?? null;
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (importStatus === 'loading' || importStatus === 'success') return;
|
||||||
|
if (importStatus === 'error') {
|
||||||
|
setImportStatus('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportStatus('loading');
|
||||||
|
|
||||||
|
const parts = contact.displayName.trim().split(/\s+/);
|
||||||
|
const firstName = parts[0] ?? '';
|
||||||
|
const lastName = parts.length > 1 ? parts.slice(1).join(' ') : undefined;
|
||||||
|
|
||||||
|
const payload: CreateContactPayload = {
|
||||||
|
type: 'PERSON',
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: email ?? undefined,
|
||||||
|
mobile: contact.mobilePhone ?? undefined,
|
||||||
|
phone: contact.businessPhones[0] ?? undefined,
|
||||||
|
position: contact.jobTitle ?? undefined,
|
||||||
|
companyName: contact.companyName ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createContact.mutateAsync(payload);
|
||||||
|
setImportStatus('success');
|
||||||
|
} catch {
|
||||||
|
setImportStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importLabel =
|
||||||
|
importStatus === 'loading' ? 'Importiere…'
|
||||||
|
: importStatus === 'success' ? '✓ Importiert'
|
||||||
|
: importStatus === 'error' ? '✕ Fehler — Erneut versuchen'
|
||||||
|
: 'CRM Import';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.backdrop}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.modal}>
|
||||||
|
{/* Schliessen */}
|
||||||
|
<button className={styles.modalClose} onClick={onClose} title="Schließen">
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M1 1l10 10M11 1L1 11" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Kopfzeile */}
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<div className={styles.modalAvatar} style={{ background: color }}>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className={styles.modalName}>{contact.displayName}</h2>
|
||||||
|
{contact.jobTitle && <div className={styles.modalJobTitle}>{contact.jobTitle}</div>}
|
||||||
|
{contact.companyName && <div className={styles.modalCompany}>{contact.companyName}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kontaktdetails */}
|
||||||
|
<div className={styles.modalDetails}>
|
||||||
|
{contact.emailAddresses.map((ea, i) =>
|
||||||
|
ea.address ? (
|
||||||
|
<div key={i} className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>
|
||||||
|
{i === 0 ? 'E-Mail' : `E-Mail ${i + 1}`}
|
||||||
|
</span>
|
||||||
|
<a href={`mailto:${ea.address}`} className={styles.detailValue}>
|
||||||
|
{ea.address}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
{contact.mobilePhone && (
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>Mobil</span>
|
||||||
|
<a href={`tel:${contact.mobilePhone}`} className={styles.detailValue}>
|
||||||
|
{contact.mobilePhone}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contact.businessPhones.map((p, i) => (
|
||||||
|
<div key={i} className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>
|
||||||
|
{contact.businessPhones.length > 1 ? `Telefon ${i + 1}` : 'Telefon'}
|
||||||
|
</span>
|
||||||
|
<a href={`tel:${p}`} className={styles.detailValue}>
|
||||||
|
{p}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Kein Kontaktdetail vorhanden */}
|
||||||
|
{!email &&
|
||||||
|
!contact.mobilePhone &&
|
||||||
|
contact.businessPhones.length === 0 && (
|
||||||
|
<p className={styles.detailEmpty}>Keine Kontaktdaten hinterlegt.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: CRM Import */}
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<button
|
||||||
|
className={`${styles.importBtn}
|
||||||
|
${importStatus === 'success' ? styles.importBtnSuccess : ''}
|
||||||
|
${importStatus === 'error' ? styles.importBtnError : ''}`}
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={importStatus === 'loading' || importStatus === 'success'}
|
||||||
|
>
|
||||||
|
{importLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hauptkomponente ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function DashboardContactsTab() {
|
||||||
|
const { data: integrationsData } = useIntegrations();
|
||||||
|
const { data, isLoading, isError } = useOffice365Contacts();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [viewMode, setViewMode] = useState<'cards' | 'list'>('cards');
|
||||||
|
const [selected, setSelected] = useState<M365Contact | null>(null);
|
||||||
|
|
||||||
|
const isConnected =
|
||||||
|
integrationsData?.data?.some(
|
||||||
|
(i) => i.provider === 'MICROSOFT_365' && i.connected,
|
||||||
|
) ?? false;
|
||||||
|
|
||||||
|
/* ── Nicht verbunden ── */
|
||||||
|
if (!isConnected) {
|
||||||
|
return (
|
||||||
|
<div className={styles.state}>
|
||||||
|
<div className={styles.stateIcon}>📇</div>
|
||||||
|
<p className={styles.stateTitle}>Microsoft 365 nicht verbunden</p>
|
||||||
|
<p className={styles.stateSub}>
|
||||||
|
Verbinde dein Microsoft-365-Konto im Profil, um deine Kontakte zu sehen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Laden ── */
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className={styles.stateCenter}>Kontakte werden geladen…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Fehler ── */
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div className={styles.stateCenter} style={{ color: 'var(--color-error, #ef4444)' }}>
|
||||||
|
Kontakte konnten nicht geladen werden.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacts = data.data ?? [];
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
const filtered = q
|
||||||
|
? contacts.filter(
|
||||||
|
(c) =>
|
||||||
|
c.displayName.toLowerCase().includes(q) ||
|
||||||
|
(c.companyName ?? '').toLowerCase().includes(q) ||
|
||||||
|
c.emailAddresses.some((ea) => (ea.address ?? '').toLowerCase().includes(q)),
|
||||||
|
)
|
||||||
|
: contacts;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
{/* Suche */}
|
||||||
|
<div className={styles.searchWrap}>
|
||||||
|
<svg
|
||||||
|
className={styles.searchIcon}
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<circle cx="6" cy="6" r="4" />
|
||||||
|
<path d="M9 9l3 3" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.searchInput}
|
||||||
|
placeholder="Name, E-Mail oder Firma suchen…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
className={styles.searchClear}
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
title="Suche leeren"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ansicht-Toggle */}
|
||||||
|
<div className={styles.viewToggle}>
|
||||||
|
<button
|
||||||
|
className={`${styles.viewBtn} ${viewMode === 'cards' ? styles.viewBtnActive : ''}`}
|
||||||
|
onClick={() => setViewMode('cards')}
|
||||||
|
title="Kachelansicht"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||||
|
<rect x="0" y="0" width="6" height="6" rx="1" />
|
||||||
|
<rect x="8" y="0" width="6" height="6" rx="1" />
|
||||||
|
<rect x="0" y="8" width="6" height="6" rx="1" />
|
||||||
|
<rect x="8" y="8" width="6" height="6" rx="1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.viewBtn} ${viewMode === 'list' ? styles.viewBtnActive : ''}`}
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
title="Listenansicht"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M1 3h12M1 7h12M1 11h12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={styles.count}>{filtered.length} Kontakte</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Listenansicht-Header */}
|
||||||
|
{viewMode === 'list' && (
|
||||||
|
<div className={styles.listHeader}>
|
||||||
|
<div /> {/* Avatar-Platz */}
|
||||||
|
<div className={styles.listHeaderCell}>Name</div>
|
||||||
|
<div className={styles.listHeaderCell}>Firma</div>
|
||||||
|
<div className={styles.listHeaderCell}>Jobtitel</div>
|
||||||
|
<div className={styles.listHeaderCell}>E-Mail</div>
|
||||||
|
<div className={styles.listHeaderCell}>Telefon</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kacheln oder Liste */}
|
||||||
|
<div className={viewMode === 'cards' ? styles.grid : styles.list}>
|
||||||
|
{filtered.map((c) =>
|
||||||
|
viewMode === 'cards' ? (
|
||||||
|
<ContactCard key={c.id} contact={c} onClick={() => setSelected(c)} />
|
||||||
|
) : (
|
||||||
|
<ContactRow key={c.id} contact={c} onClick={() => setSelected(c)} />
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keine Ergebnisse */}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className={styles.empty}>Keine Kontakte gefunden.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detail-Modal */}
|
||||||
|
{selected && (
|
||||||
|
<ContactModal contact={selected} onClose={() => setSelected(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { AnalogClock } from '../components/AnalogClock';
|
||||||
import { DashboardEmailTab } from './DashboardEmailTab';
|
import { DashboardEmailTab } from './DashboardEmailTab';
|
||||||
import { DashboardCalendarTab, DayAgenda } from './DashboardCalendarTab';
|
import { DashboardCalendarTab, DayAgenda } from './DashboardCalendarTab';
|
||||||
import { DashboardTasksTab } from './DashboardTasksTab';
|
import { DashboardTasksTab } from './DashboardTasksTab';
|
||||||
|
import { DashboardContactsTab } from './DashboardContactsTab';
|
||||||
import {
|
import {
|
||||||
useIntegrations,
|
useIntegrations,
|
||||||
useOffice365CalendarRange,
|
useOffice365CalendarRange,
|
||||||
|
|
@ -685,16 +686,6 @@ function HomeTab({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComingSoonTab({ label }: { label: string }) {
|
|
||||||
return (
|
|
||||||
<div className={styles.comingSoon}>
|
|
||||||
<span className={styles.comingSoonIcon}>🚧</span>
|
|
||||||
<p className={styles.comingSoonTitle}>{label}</p>
|
|
||||||
<p className={styles.comingSoonSub}>Inhalt folgt in Kürze.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
|
|
@ -736,7 +727,7 @@ export function DashboardPage() {
|
||||||
{activeTab === 'emails' && <DashboardEmailTab />}
|
{activeTab === 'emails' && <DashboardEmailTab />}
|
||||||
{activeTab === 'calendar' && <DashboardCalendarTab />}
|
{activeTab === 'calendar' && <DashboardCalendarTab />}
|
||||||
{activeTab === 'tasks' && <DashboardTasksTab />}
|
{activeTab === 'tasks' && <DashboardTasksTab />}
|
||||||
{activeTab === 'contacts' && <ComingSoonTab label="Kontakte" />}
|
{activeTab === 'contacts' && <DashboardContactsTab />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue