mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 10:06:39 +02:00
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>
531 lines
16 KiB
TypeScript
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 →</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>
|
|
);
|
|
}
|