mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 22:36:38 +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<{
|
||||
logo: string | null;
|
||||
sidebarColor: string | null;
|
||||
logoWidth: number | null;
|
||||
sidebarWidth: number | null;
|
||||
}> {
|
||||
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 {
|
||||
const data = JSON.parse(raw);
|
||||
return {
|
||||
logo: data.logo || null,
|
||||
logo: data.logo || null,
|
||||
sidebarColor: data.sidebarColor || null,
|
||||
logoWidth: typeof data.logoWidth === 'number' ? data.logoWidth : null,
|
||||
sidebarWidth: typeof data.sidebarWidth === 'number' ? data.sidebarWidth : null,
|
||||
};
|
||||
} catch {
|
||||
// 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)
|
||||
@ApiOperation({ summary: 'Branding-Einstellungen speichern (Admin)' })
|
||||
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 }> {
|
||||
if (body.logo && body.logo.length > 500_000) {
|
||||
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 = {
|
||||
logo: body.logo || null,
|
||||
logo: body.logo || null,
|
||||
sidebarColor: body.sidebarColor || null,
|
||||
logoWidth,
|
||||
sidebarWidth,
|
||||
};
|
||||
|
||||
await this.redis.set(BRANDING_LOGO_KEY, JSON.stringify(data));
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import api from '../api/client';
|
|||
interface BrandingData {
|
||||
logo: string | null;
|
||||
sidebarColor: string | null;
|
||||
logoWidth: number | null;
|
||||
sidebarWidth: number | null;
|
||||
}
|
||||
|
||||
const SIDEBAR_PRESETS = [
|
||||
|
|
@ -63,6 +65,8 @@ export function AdminCustomizePage() {
|
|||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [logo, setLogo] = useState<string | null>(null);
|
||||
const [sidebarColor, setSidebarColor] = useState<string>('#1e293b');
|
||||
const [logoWidth, setLogoWidth] = useState<number>(160);
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(240);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
|
|
@ -78,6 +82,8 @@ export function AdminCustomizePage() {
|
|||
if (data) {
|
||||
setLogo(data.logo);
|
||||
setSidebarColor(data.sidebarColor || '#1e293b');
|
||||
setLogoWidth(data.logoWidth ?? 160);
|
||||
setSidebarWidth(data.sidebarWidth ?? 240);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [data]);
|
||||
|
|
@ -86,6 +92,8 @@ export function AdminCustomizePage() {
|
|||
mutationFn: async (branding: {
|
||||
logo: string | null;
|
||||
sidebarColor: string;
|
||||
logoWidth: number;
|
||||
sidebarWidth: number;
|
||||
}) => {
|
||||
const res = await api.post('/settings/branding', branding);
|
||||
return res.data;
|
||||
|
|
@ -129,7 +137,7 @@ export function AdminCustomizePage() {
|
|||
};
|
||||
|
||||
const handleSave = () => {
|
||||
saveMutation.mutate({ logo, sidebarColor });
|
||||
saveMutation.mutate({ logo, sidebarColor, logoWidth, sidebarWidth });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -169,6 +177,7 @@ export function AdminCustomizePage() {
|
|||
SVG mit transparentem Hintergrund, max. 500KB.
|
||||
</p>
|
||||
|
||||
{/* Vorschau + Upload */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
@ -188,6 +197,7 @@ export function AdminCustomizePage() {
|
|||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
background: sidebarColor,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{logo ? (
|
||||
|
|
@ -195,7 +205,7 @@ export function AdminCustomizePage() {
|
|||
src={logo}
|
||||
alt="Logo"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxWidth: `${Math.round(logoWidth * 0.72)}px`,
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
|
|
@ -242,6 +252,29 @@ export function AdminCustomizePage() {
|
|||
)}
|
||||
</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>
|
||||
|
||||
{/* Sidebar-Farbe */}
|
||||
|
|
@ -414,6 +447,70 @@ export function AdminCustomizePage() {
|
|||
</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 */}
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -150,12 +150,16 @@ export function AppLayout() {
|
|||
const { data: branding } = useQuery<{
|
||||
logo: string | null;
|
||||
sidebarColor: string | null;
|
||||
logoWidth: number | null;
|
||||
sidebarWidth: number | null;
|
||||
}>({
|
||||
queryKey: ['settings', 'branding'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{
|
||||
logo: string | null;
|
||||
sidebarColor: string | null;
|
||||
logoWidth: number | null;
|
||||
sidebarWidth: number | null;
|
||||
}>('/settings/branding');
|
||||
return res.data;
|
||||
},
|
||||
|
|
@ -172,11 +176,10 @@ export function AppLayout() {
|
|||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`${styles.sidebar} ${collapsed ? styles.sidebarCollapsed : ''}`}
|
||||
style={
|
||||
branding?.sidebarColor
|
||||
? { background: branding.sidebarColor }
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
...(branding?.sidebarColor ? { background: branding.sidebarColor } : {}),
|
||||
...(!collapsed && branding?.sidebarWidth ? { width: branding.sidebarWidth } : {}),
|
||||
}}
|
||||
>
|
||||
<div className={styles.brand}>
|
||||
{!collapsed &&
|
||||
|
|
@ -186,7 +189,7 @@ export function AppLayout() {
|
|||
alt="Logo"
|
||||
style={{
|
||||
maxHeight: 44,
|
||||
maxWidth: 160,
|
||||
maxWidth: branding.logoWidth ?? 160,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
|
|
@ -578,7 +581,7 @@ export function AppLayout() {
|
|||
<main
|
||||
className={styles.main}
|
||||
style={{
|
||||
marginLeft: collapsed ? 60 : undefined,
|
||||
marginLeft: collapsed ? 60 : (branding?.sidebarWidth ?? undefined),
|
||||
}}
|
||||
>
|
||||
{/* 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 { DashboardCalendarTab, DayAgenda } from './DashboardCalendarTab';
|
||||
import { DashboardTasksTab } from './DashboardTasksTab';
|
||||
import { DashboardContactsTab } from './DashboardContactsTab';
|
||||
import {
|
||||
useIntegrations,
|
||||
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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function DashboardPage() {
|
||||
|
|
@ -736,7 +727,7 @@ export function DashboardPage() {
|
|||
{activeTab === 'emails' && <DashboardEmailTab />}
|
||||
{activeTab === 'calendar' && <DashboardCalendarTab />}
|
||||
{activeTab === 'tasks' && <DashboardTasksTab />}
|
||||
{activeTab === 'contacts' && <ComingSoonTab label="Kontakte" />}
|
||||
{activeTab === 'contacts' && <DashboardContactsTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue