INSIGHT-MVP/packages/frontend/src/shell/AppLayout.tsx
Thomas Reitz c739dce161 feat: CRM Frontend-Modul mit Kontakte, Deals, Pipelines und Aktivitäten
Komplette CRM-Frontend-Integration in die bestehende React-GUI:

- Types, API-Client und React Query Hooks für alle CRM-Entitäten
- Kontakte: Liste mit Suche/Filter, Detail mit Aktivitäten-Timeline, Create/Edit Modal
- Deals: Liste mit Pipeline/Stage/Status-Filter, Detail mit Fortschrittsbalken, Create/Edit Modal
- Pipelines: Verwaltungsseite mit klappbaren Cards und Stage-Management
- Aktivitäten: Formular-Modal für Notizen, Anrufe, E-Mails, Meetings, Aufgaben
- CRM-Navigation in Sidebar (aufklappbar, mit Inline-SVG-Icons)
- Routen in App.tsx für alle CRM-Seiten
- Vite-Proxy für lokale CRM-API-Entwicklung

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:13:02 +01:00

531 lines
16 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../auth/AuthContext';
import { UserAvatar } from '../components/UserAvatar';
import api from '../api/client';
import { useTheme } from '../theme/ThemeContext';
import styles from './AppLayout.module.css';
interface ExternalLink {
id: string;
label: string;
url: string;
sortOrder: number;
customIcon?: string;
}
/** Favicon ueber Backend-Proxy laden (cached in Redis) */
function FaviconImg({
url,
label,
customIcon,
}: {
url: string;
label: string;
customIcon?: string;
}) {
const [faviconUrl, setFaviconUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const fetchFavicon = useCallback(async () => {
try {
new URL(url);
const res = await api.get<{ faviconUrl: string | null }>(
`/settings/favicon?url=${encodeURIComponent(url)}`,
);
if (res.data.faviconUrl) {
setFaviconUrl(res.data.faviconUrl);
setFailed(false);
} else {
setFailed(true);
}
} catch {
setFailed(true);
}
}, [url]);
useEffect(() => {
fetchFavicon();
}, [fetchFavicon]);
// Eigenes Icon hat Vorrang
if (customIcon) {
return (
<img
src={customIcon}
alt=""
style={{
width: 16,
height: 16,
objectFit: 'contain',
borderRadius: 2,
}}
/>
);
}
if (failed || !faviconUrl) {
return (
<span
style={{
display: 'flex',
width: 16,
height: 16,
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.625rem',
fontWeight: 700,
background: 'rgba(255,255,255,0.15)',
borderRadius: 2,
color: 'rgba(255,255,255,0.8)',
flexShrink: 0,
}}
>
{label.charAt(0).toUpperCase()}
</span>
);
}
return (
<img
src={faviconUrl}
alt=""
style={{
width: 16,
height: 16,
objectFit: 'contain',
borderRadius: 2,
}}
onError={() => setFailed(true)}
/>
);
}
const THEME_OPTIONS = [
{ value: 'light' as const, label: 'Hell', icon: '☀' },
{ value: 'dark' as const, label: 'Dunkel', icon: '☾' },
{ value: 'system' as const, label: 'System', icon: '⚙' },
];
export function AppLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { mode, setMode } = useTheme();
const [crmOpen, setCrmOpen] = useState(true);
const [appsOpen, setAppsOpen] = useState(true);
const [collapsed, setCollapsed] = useState(() => {
return localStorage.getItem('sidebar-collapsed') === 'true';
});
const toggleCollapsed = () => {
setCollapsed((prev) => {
const next = !prev;
localStorage.setItem('sidebar-collapsed', String(next));
return next;
});
};
const { data: externalLinks } = useQuery<ExternalLink[]>({
queryKey: ['settings', 'external-links'],
queryFn: async () => {
const res = await api.get<ExternalLink[]>('/settings/external-links');
return res.data;
},
staleTime: 5 * 60 * 1000,
});
const { data: branding } = useQuery<{
logo: string | null;
sidebarColor: string | null;
}>({
queryKey: ['settings', 'branding'],
queryFn: async () => {
const res = await api.get<{
logo: string | null;
sidebarColor: string | null;
}>('/settings/branding');
return res.data;
},
staleTime: 5 * 60 * 1000,
});
const handleLogout = async () => {
await logout();
navigate('/login');
};
return (
<div className={styles.layout}>
{/* Sidebar */}
<aside
className={`${styles.sidebar} ${collapsed ? styles.sidebarCollapsed : ''}`}
style={
branding?.sidebarColor
? { background: branding.sidebarColor }
: undefined
}
>
<div className={styles.brand}>
{!collapsed &&
(branding?.logo ? (
<img
src={branding.logo}
alt="Logo"
style={{
maxHeight: 44,
maxWidth: 160,
objectFit: 'contain',
}}
/>
) : (
<h2>INSIGHT</h2>
))}
<button
className={styles.collapseBtn}
onClick={toggleCollapsed}
title={collapsed ? 'Menue ausklappen' : 'Menue einklappen'}
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
{collapsed ? (
<>
<path d="M3 4.5h12M3 9h12M3 13.5h12" />
</>
) : (
<>
<path d="M11 4l-5 5 5 5" />
</>
)}
</svg>
</button>
</div>
<nav className={styles.nav}>
<NavLink
to="/"
end
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Dashboard"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2 6l6-4 6 4v7a1 1 0 01-1 1H3a1 1 0 01-1-1V6z" />
<path d="M6 14V8h4v6" />
</svg>
{!collapsed && 'Dashboard'}
</NavLink>
{/* CRM-Bereich (aufklappbar) */}
{!collapsed ? (
<button
className={styles.navSectionToggle}
onClick={() => setCrmOpen((p) => !p)}
>
<span>CRM</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`${styles.chevron} ${crmOpen ? styles.chevronOpen : ''}`}
>
<path d="M3 4.5l3 3 3-3" />
</svg>
</button>
) : (
<div className={styles.navDivider} />
)}
{(crmOpen || collapsed) && (
<>
<NavLink
to="/crm/contacts"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Kontakte"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="5" r="3" />
<path d="M2 14c0-2.761 2.686-5 6-5s6 2.239 6 5" />
</svg>
{!collapsed && 'Kontakte'}
</NavLink>
<NavLink
to="/crm/deals"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Deals"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="5" width="14" height="9" rx="1" />
<path d="M5 5V3a2 2 0 012-2h2a2 2 0 012 2v2" />
<path d="M1 9h14" />
</svg>
{!collapsed && 'Deals'}
</NavLink>
<NavLink
to="/crm/pipelines"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Pipelines"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="2" width="4" height="12" rx="0.5" />
<rect x="6" y="2" width="4" height="8" rx="0.5" />
<rect x="11" y="2" width="4" height="10" rx="0.5" />
</svg>
{!collapsed && 'Pipelines'}
</NavLink>
</>
)}
{/* Externe Links (aufklappbar) */}
{externalLinks && externalLinks.length > 0 && (
<>
{!collapsed ? (
<button
className={styles.navSectionToggle}
onClick={() => setAppsOpen((p) => !p)}
>
<span>Anwendungen</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`${styles.chevron} ${appsOpen ? styles.chevronOpen : ''}`}
>
<path d="M3 4.5l3 3 3-3" />
</svg>
</button>
) : (
<div className={styles.navDivider} />
)}
{(appsOpen || collapsed) &&
externalLinks.map((link) => (
<a
key={link.id}
href={link.url}
onClick={(e) => {
e.preventDefault();
window.open(link.url, link.label, 'popup,noopener');
}}
className={styles.navLink}
title={link.label}
>
<FaviconImg url={link.url} label={link.label} customIcon={link.customIcon} />
{!collapsed && (
<>
{link.label}
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
style={{ marginLeft: 'auto', opacity: 0.4 }}
>
<path d="M6 1h3v3" />
<path d="M4 6l5-5" />
</svg>
</>
)}
</a>
))}
</>
)}
</nav>
{/* Admin-Link (nur PLATFORM_ADMIN) */}
{user?.role === 'PLATFORM_ADMIN' && (
<div className={styles.adminSection}>
<NavLink
to="/admin/users"
className={({ isActive }) =>
`${styles.adminLink} ${isActive ? styles.adminLinkActive : ''}`
}
title="Administration"
style={({ isActive }) => {
const isAdminPath =
window.location.pathname.startsWith('/admin');
return isAdminPath && !isActive
? {
color: 'white',
background: 'rgba(96, 165, 250, 0.15)',
}
: {};
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
{!collapsed && 'Administration'}
</NavLink>
</div>
)}
<div className={styles.userInfo}>
{collapsed ? (
<div
className={styles.userProfile}
onClick={() => navigate('/profile')}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') navigate('/profile');
}}
title={`${user?.firstName} ${user?.lastName}`}
>
<UserAvatar
firstName={user?.firstName ?? ''}
lastName={user?.lastName ?? ''}
avatar={user?.avatar}
size={32}
/>
</div>
) : (
<>
<div
className={styles.userProfile}
onClick={() => navigate('/profile')}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
navigate('/profile');
}}
>
<div className={styles.userProfileInner}>
<UserAvatar
firstName={user?.firstName ?? ''}
lastName={user?.lastName ?? ''}
avatar={user?.avatar}
size={36}
/>
<div className={styles.userDetails}>
<div className={styles.userName}>
{user?.firstName} {user?.lastName}
</div>
<div className={styles.userEmail}>{user?.email}</div>
</div>
</div>
<div className={styles.profileHint}>Zum Profil &rarr;</div>
</div>
<button className={styles.logoutBtn} onClick={handleLogout}>
Abmelden
</button>
</>
)}
</div>
{/* Theme Toggle (unter dem Profil) */}
<div className={styles.themeToggle}>
{collapsed ? (
<button
className={styles.themeBtn}
onClick={() => {
const next =
mode === 'light'
? 'dark'
: mode === 'dark'
? 'system'
: 'light';
setMode(next);
}}
title={`Theme: ${THEME_OPTIONS.find((o) => o.value === mode)?.label}`}
>
{THEME_OPTIONS.find((o) => o.value === mode)?.icon}
</button>
) : (
<div className={styles.themeBtnGroup}>
{THEME_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`${styles.themeOption} ${mode === opt.value ? styles.themeOptionActive : ''}`}
onClick={() => setMode(opt.value)}
title={opt.label}
>
<span className={styles.themeIcon}>{opt.icon}</span>
{opt.label}
</button>
))}
</div>
)}
</div>
</aside>
{/* Main Content */}
<main
className={styles.main}
style={{
marginLeft: collapsed ? 60 : undefined,
}}
>
<Outlet />
</main>
</div>
);
}