feat: Projekt Scanner integrieren, Whisper in die Einstellungen
Der eigenstaendige Projekt Scanner zieht komplett ins CRM ein und laeuft parallel zur bestehenden App weiter - jede geteilte Ressource ist verdoppelt statt geteilt, damit die alte Installation unberuehrt bleibt: - eigene Datenbank perinco_scanner, legt sich beim Start selbst an - eigener SCANNER_ENCRYPTION_KEY, eigenes Upload-Verzeichnis - eigener Scheduler mit Zeitversatz, damit nicht beide Instanzen gleichzeitig mit denselben Portal-Cookies scrapen (Rate-Limits, CAPTCHA, doppelter RAM) - Sperre gegen ueberlappende Laeufe: Scheduler und "Jetzt scannen" teilen sie, ein zweiter Aufruf bekommt 409 statt doppelter OpenAI-Kosten Backend unter /api/scanner hinter der CRM-Anmeldung; die HTTP-Basic-Auth des Originals entfaellt. CV-PDFs kommen als Stream mit Token statt aus einem statischen Verzeichnis. Bestandsdaten holt "npm run scanner:import" aus der alten Datenbank, ohne sie zu veraendern. Oberflaeche in Tailwind und CI-Farben neu gebaut: Menuepunkt Scanner mit aufklappbaren Unterpunkten, Einstellungen als eigener Reiter. Whisper hat keinen eigenen Menuepunkt mehr, sondern ist ein Reiter in den Einstellungen. Damit diese Geraeteeinstellung jeder Rolle offensteht, sind die Einstellungen jetzt fuer alle sichtbar - welche Reiter erscheinen, entscheidet die Rolle. Ausserdem: kein festes Admin-Passwort mehr im Quelltext, playwright exakt auf 1.60.0 gepinnt (nutzt den vorhandenen Chromium-Build des Servers), technische Dokumentation mit Funktionszeichnungen unter docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { SCANNER_VIEWS, type ViewState } from '../types';
|
||||
import { UserAvatar } from './ui/UserAvatar';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
interface SidebarProps {
|
||||
currentView: ViewState;
|
||||
onViewChange: (view: ViewState) => void;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
name: ViewState;
|
||||
icon: string;
|
||||
/** Abweichende Beschriftung; ohne Angabe wird der Ansichtsname gezeigt. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// Kategorien-Reiter in Anzeigereihenfolge. Weitere Kategorien lassen sich hier
|
||||
// ergaenzen - Einstellungen bleibt separat am unteren Rand.
|
||||
const CATEGORY_ITEMS: MenuItem[] = [
|
||||
{
|
||||
name: 'Opportunities',
|
||||
icon: 'M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z',
|
||||
},
|
||||
{
|
||||
name: 'Kalender',
|
||||
icon: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
|
||||
},
|
||||
{
|
||||
name: 'Kontakte',
|
||||
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Projekt Scanner. Der Menuepunkt klappt die Reiter des Scanners darunter auf -
|
||||
* "Scanner" selbst ist dabei das Dashboard. Die Einstellungen des Scanners
|
||||
* stehen nicht hier, sondern als Reiter in den CRM-Einstellungen.
|
||||
*/
|
||||
const SCANNER_ITEM: MenuItem = {
|
||||
name: 'Scanner',
|
||||
icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
|
||||
};
|
||||
|
||||
const SCANNER_CHILDREN: MenuItem[] = [
|
||||
{
|
||||
name: 'Scanner',
|
||||
label: 'Dashboard',
|
||||
icon: 'M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z',
|
||||
},
|
||||
{
|
||||
name: 'Unpassend',
|
||||
icon: 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636',
|
||||
},
|
||||
{
|
||||
name: 'Suchagenten',
|
||||
icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
|
||||
},
|
||||
{
|
||||
name: 'Börsen-Konten',
|
||||
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||
},
|
||||
];
|
||||
|
||||
/*
|
||||
* Whisper hat keinen eigenen Menuepunkt mehr - es ist ein Reiter in den
|
||||
* Einstellungen. Deshalb sind die Einstellungen jetzt fuer jede Rolle sichtbar:
|
||||
* jeder pflegt sein Konto und richtet sein eigenes Diktat ein. Welche Reiter
|
||||
* erscheinen, entscheidet die Rolle innerhalb der Einstellungen.
|
||||
*/
|
||||
const SETTINGS_ITEM: MenuItem = {
|
||||
name: 'Einstellungen',
|
||||
icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
|
||||
};
|
||||
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(() =>
|
||||
typeof window === 'undefined' ? false : window.innerWidth < 1024
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsMobile(window.innerWidth < 1024);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isOpen, onToggle }) => {
|
||||
const { user, logout } = useAuth();
|
||||
const isMobile = useIsMobile();
|
||||
const showLabels = isOpen || isMobile;
|
||||
|
||||
const isScannerView = SCANNER_VIEWS.includes(currentView);
|
||||
const [isScannerOpen, setIsScannerOpen] = useState(isScannerView);
|
||||
|
||||
// Wer per Klick im Scanner landet, soll die Unterpunkte auch sehen.
|
||||
useEffect(() => {
|
||||
if (isScannerView) setIsScannerOpen(true);
|
||||
}, [isScannerView]);
|
||||
|
||||
const renderItem = (item: MenuItem, options: { indented?: boolean } = {}) => {
|
||||
const isActive = currentView === item.name;
|
||||
return (
|
||||
<button
|
||||
key={item.name}
|
||||
onClick={() => {
|
||||
onViewChange(item.name);
|
||||
if (isMobile) onToggle();
|
||||
}}
|
||||
title={!showLabels ? item.label ?? item.name : undefined}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={`group flex w-full items-center rounded-xl transition ${
|
||||
showLabels
|
||||
? `gap-3 py-2.5 ${options.indented ? 'pl-9 pr-3' : 'px-3'}`
|
||||
: 'justify-center px-0 py-2.5'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-brand-500 text-white shadow-card'
|
||||
: 'text-ink-600 hover:bg-brand-50 hover:text-brand-700'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className={`shrink-0 ${options.indented && showLabels ? 'h-4 w-4' : 'h-5 w-5'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={isActive ? 2.2 : 1.7}
|
||||
d={item.icon}
|
||||
/>
|
||||
</svg>
|
||||
{showLabels && (
|
||||
<span className={`truncate font-bold ${options.indented ? 'text-[13px]' : 'text-sm'}`}>
|
||||
{item.label ?? item.name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scanner-Gruppe: der Klick oeffnet die Unterpunkte und wechselt gleich auf
|
||||
* das Dashboard. Bei eingeklappter Leiste gibt es nur das Symbol, die
|
||||
* Unterpunkte erscheinen erst mit den Beschriftungen.
|
||||
*/
|
||||
const renderScannerGroup = () => {
|
||||
const isActive = currentView === 'Scanner';
|
||||
return (
|
||||
<div key="scanner-group">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsScannerOpen((open) => (isScannerView ? !open : true));
|
||||
onViewChange('Scanner');
|
||||
if (isMobile) onToggle();
|
||||
}}
|
||||
title={!showLabels ? 'Scanner' : undefined}
|
||||
aria-expanded={isScannerOpen}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={`group flex w-full items-center rounded-xl transition ${
|
||||
showLabels ? 'gap-3 px-3 py-2.5' : 'justify-center px-0 py-2.5'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-brand-500 text-white shadow-card'
|
||||
: isScannerView
|
||||
? 'bg-brand-50 text-brand-700'
|
||||
: 'text-ink-600 hover:bg-brand-50 hover:text-brand-700'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-5 w-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={isActive ? 2.2 : 1.7}
|
||||
d={SCANNER_ITEM.icon}
|
||||
/>
|
||||
</svg>
|
||||
{showLabels && (
|
||||
<>
|
||||
<span className="truncate text-sm font-bold">Scanner</span>
|
||||
<svg
|
||||
className={`ml-auto h-4 w-4 shrink-0 transition-transform ${
|
||||
isScannerOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showLabels && isScannerOpen && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{SCANNER_CHILDREN.map((child) => renderItem(child, { indented: true }))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && isMobile && (
|
||||
<div className="fixed inset-0 z-20 bg-ink-950/40 backdrop-blur-sm lg:hidden" onClick={onToggle} />
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-30 flex h-full flex-col overflow-hidden border-r border-ink-200 bg-surface transition-all duration-300 lg:static ${
|
||||
isOpen ? 'w-64 translate-x-0' : 'w-0 -translate-x-full lg:w-[76px] lg:translate-x-0'
|
||||
}`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div
|
||||
className={`flex shrink-0 items-center border-b border-ink-100 ${
|
||||
showLabels ? 'px-5 py-5' : 'justify-center px-0 py-5'
|
||||
}`}
|
||||
>
|
||||
{showLabels ? (
|
||||
<img src="/logo.png" alt="perinco CRM" className="logo-adaptive h-9 w-auto" />
|
||||
) : (
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-brand-500 text-base font-extrabold text-white">
|
||||
p
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kategorien */}
|
||||
<nav className="scrollbar-slim flex-1 space-y-1 overflow-y-auto overflow-x-hidden px-3 py-4">
|
||||
{showLabels && (
|
||||
<p className="mb-2 px-3 text-[10px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
||||
Kategorien
|
||||
</p>
|
||||
)}
|
||||
{CATEGORY_ITEMS.map((item) => renderItem(item))}
|
||||
{renderScannerGroup()}
|
||||
</nav>
|
||||
|
||||
{/* Einstellungen unten - Inhalt richtet sich nach der Rolle */}
|
||||
<div className="shrink-0 space-y-1 border-t border-ink-100 px-3 py-3">
|
||||
{renderItem(SETTINGS_ITEM)}
|
||||
</div>
|
||||
|
||||
{/* Benutzer */}
|
||||
<div
|
||||
className={`shrink-0 border-t border-ink-100 ${
|
||||
showLabels ? 'p-4' : 'flex flex-col items-center gap-3 p-3'
|
||||
}`}
|
||||
>
|
||||
<div className="flex w-full min-w-0 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{user ? (
|
||||
<UserAvatar user={user} size={36} />
|
||||
) : (
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-100 text-sm font-extrabold text-brand-700">
|
||||
?
|
||||
</div>
|
||||
)}
|
||||
{showLabels && (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold text-ink-900">{user?.name}</p>
|
||||
<p className="truncate text-xs text-ink-400">
|
||||
{user?.role === 'admin' ? 'Administrator' : 'Benutzer'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showLabels && (
|
||||
<button
|
||||
onClick={logout}
|
||||
title="Abmelden"
|
||||
className="shrink-0 rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!showLabels && (
|
||||
<button
|
||||
onClick={logout}
|
||||
title="Abmelden"
|
||||
className="rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user