JWT_EXPIRES_IN faellt ohne Eintrag in .env jetzt auf 7d statt 12h zurueck.
Doku und .env.example angepasst.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Kleines Canvas-Konfetti ohne Abhaengigkeit (src/lib/confetti.ts), ausgeloest
beim Phasenwechsel in der Pipeline und beim Speichern im Dialog. Entfaellt bei
prefers-reduced-motion.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Neuer Admin-Reiter "KI": zeigt die letzten vier Zeichen des hinterlegten
OpenAI-Schluessels samt Quelle, nimmt einen neuen an (vorher gegen
/v1/models geprueft) und laesst je Einsatzzweck das Modell per Dropdown
waehlen - Scanner-Bewertung, Abgleich "bereits beworben", erweiterte Suche.
Die Auswahl kommt live von OpenAI, Vorgaben sind die bisher fest
verdrahteten Modelle. Reasoning-Modelle bekommen reasoning_effort statt
temperature, damit ein Wechsel keinen 400er ausloest.
Schluesselfeld aus dem Scanner-Reiter entfernt; Doku in docs/14-ki.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Neben "Cookie-Import" auf den Boersen-Konten steht ein Fragezeichen. Beim
Ueberfahren (oder Fokus) zeigt es den Export-Dialog von Cookie-Editor mit
kurzer Anleitung. Bild unter public/help/.
Doku: Die Zugangsdaten loesen keinen automatischen Login aus - das stand
bisher falsch in der Anleitung.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Die Uebernahme aus der Akquise schrieb den Link bisher nur in die Notizen.
Jetzt hat die Opportunity eine Spalte `link`, gefuellt bei Uebernahme und
Kopie; im Dialog steht sie rechts neben der Phase mit Knopf "Oeffnen".
schema.sql traegt den Link fuer aeltere Uebernahmen nach. Die Pruefung liegt
in server/src/link.js und gilt fuer Akquise und Opportunities gleich.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bisher verschwand ein Fund mit der Uebernahme aus Eingang und Archiv.
Beim Durchgehen blieb damit offen, ob er schon angefasst wurde. Jetzt
bleibt der Eintrag stehen und zeigt statt "Opportunity" den blauen Knopf
"Uebernommen" mit Haken; ein Klick springt zur Opportunity.
Dafuer entscheidet nicht mehr IS_OPEN ueber die Listen, sondern die neuen
Regeln IN_INBOX und RETURNED: ein Rueckfaeller (Opportunity im Papierkorb
oder geloescht) gehoert weiter ins Archiv, alles andere bleibt in seiner
Liste. Zaehler und Menuezahl folgen derselben Regel. IS_OPEN bleibt fuer
den Loeschlauf: was zu einer lebenden Opportunity gehoert, wird nicht
weggeraeumt.
Solange die Opportunity lebt, sind Bearbeiten, Loeschen und "Zurueck in
die Akquise" am Eintrag gesperrt - er ist ihr Herkunftsnachweis, und der
Rueckweg von der Opportunity braucht ihn. Das Loeschen weist auch das
Backend ab, nicht nur die Oberflaeche.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Beim Anlegen stand als Zustaendiger bisher der erste Benutzer der Liste
(in der Praxis "Administrator"). Jetzt ist es - wie im Kontaktformular -
der angemeldete Benutzer, mit users[0] als Rueckfallebene. Beim
Bearbeiten bleibt die gespeicherte Zustaendigkeit unangetastet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Das Secret der App-Registrierung laeuft in Entra ab. Bisher hiess das: jemand
mit SSH-Zugang aendert die .env und startet den Dienst neu - und niemand sieht
vorher, wann es soweit ist. Laeuft es aus, stehen Anmeldung UND
Postfachabgleich still, denn beide haengen an derselben Registrierung.
Neu unter Einstellungen > Anmeldung: Secret eintragen, Ablaufdatum hinterlegen,
Restlaufzeit ablesen. Ab 30 Tagen vor dem Ablauf bekommen Administratoren ueber
jeder Ansicht ein Hinweisband, das direkt dorthin fuehrt; nach dem Ablauf wird
es rot. Der Hinweis reist mit /api/badges mit, das die App ohnehin bei jedem
Laden abruft.
Bewusst so entschieden:
- Das Secret muss dafuer in die Datenbank, entgegen der bisherigen Regel
"keine Geheimnisse in crm_settings". Es liegt deshalb AES-256-GCM-
verschluesselt dort; der Schluessel kommt aus CRM_ENCRYPTION_KEY, ersatz-
weise JWT_SECRET, und steht damit nie im selben Dump.
- Vor dem Speichern holt das CRM mit dem eingegebenen Wert ein Token bei
Microsoft. Ein Tippfehler wird gar nicht erst uebernommen.
- Gelesen wird Datenbank vor .env; der .env-Wert bleibt Rueckfallebene, und
"Hinterlegtes Secret verwerfen" schaltet dorthin zurueck.
- Passt der Schluessel nicht mehr, meldet die Oberflaeche das ausdruecklich,
statt still auf die .env zurueckzufallen - sonst sucht man den Fehler an
der falschen Stelle.
- Das Ablaufdatum wird von Hand eingetragen. Aus Entra lesen braeuchte
Application.Read.All, die die App bewusst nicht hat.
MAIL_CONFIGURED und SSO_CONFIGURED sind dadurch von Konstanten zu den
Funktionen mailConfigured() und ssoConfigured() geworden - die Pruefung muss
jetzt in die Datenbank sehen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nicht jedes CRM-Konto traegt eine @perinco.de-Adresse. Ohne den Eintrag weist
domainAllowed() diese Leute ab, obwohl Tenant und CRM-Konto stimmen. Auf dem
Server ist MS_SSO_ALLOWED_DOMAINS bereits entsprechend gesetzt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OpenID Connect mit Authorization Code Flow und PKCE, vollstaendig
serverseitig - der Browser sieht weder Tenant- noch Client-ID. Genutzt wird
dieselbe App-Registrierung wie die Postfachanbindung, also kein zweites
Secret; delegiert statt app-only.
Nach dem Token-Tausch beweist ein Aufruf von /me bei Graph, dass das
Zugriffstoken gilt, und liefert die Objekt-ID. Sie landet in der neuen Spalte
crm_users.entra_oid und ist danach die stabile Verknuepfung - die E-Mail-
Adresse eines Menschen kann sich aendern, die Objekt-ID nicht.
Bewusst so entschieden:
- Es werden keine Konten angelegt. Wer kein CRM-Konto mit derselben Adresse
hat, wird abgewiesen; Zugriff vergibt weiter die Nutzerverwaltung.
- Die Anmeldung mit Benutzername und Passwort bleibt fuer alle bestehen -
Notzugang, falls Entra oder das Secret ausfaellt.
- Das CRM-Token kommt als Einmal-Ticket zurueck, nicht in der Adresszeile:
die Rueckleitung ist ein GET und stuende sonst in Browserverlauf und
Apache-Log.
- Keine Signaturpruefung des ID-Tokens und damit keine JWKS-Bibliothek. Es
kommt ueber TLS direkt vom Token-Endpunkt (OIDC Core 3.1.3.7); geprueft
werden aud, tid, nonce und exp, und der Graph-Aufruf belegt den Rest.
Ohne MS_SSO_ENABLED=true bleibt alles aus und die Anmeldemaske sieht aus wie
bisher.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Die festen Huegel weichen drei Wellenkacheln (aura-wave-back/mid/front).
Jede ist genau eine Sinusperiode breit, wiederholt sich waagerecht und
wandert in 40 s um ihre eigene Kachelbreite - nahtlos und in beiden
Modi. Unterschiedliche Kachelbreiten ergeben unterschiedliche
Geschwindigkeiten. Loest aura-hills ab; bei prefers-reduced-motion
stehen die Wellen still.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Beides lag bisher nur als nicht eingecheckte Arbeit im Arbeitsverzeichnis,
waehrend auf Gitea nur die Doku dazu stand (docs/12-suche.md). Damit ist
der Code jetzt versioniert.
Globale Suche:
- server/src/routes/search.js: Volltext ueber Kontakte, Opportunities und
Akquise, dazu die erweiterte Suche mit dem Modell des Scanners
- src/components/GlobalSearch.tsx im Kopf der Anwendung; ein Treffer
springt in die Ansicht und oeffnet den Datensatz (navigation.tsx)
ICON CRM:
- server/src/routes/integrations.js liefert die Kontakte mit gesetztem
Haekchen hinaus, server/src/routes/iconCrm.js holt die Kontakte von
drueben herein - beide nur lesend, mit festem Token statt JWT
- server/src/contactBridge.js und src/lib/contactMatch.ts erkennen
Dubletten, src/components/leads/IconMergeDialog.tsx laesst sie
zusammenfuehren
- server/src/ai.js buendelt die OpenAI-Aufrufe
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Die Seitenleiste im hellen Aura sah aus wie ein Standbild. Jetzt bewegt
@keyframes aura-hills die Grafik-Ebene in 26 s um +-9 px waagerecht und
4 px senkrecht - langsam genug, dass es beim Arbeiten nicht auffaellt.
Bewegt wird nur die zweite Hintergrundebene. Der Schleier davor bleibt
stehen, sonst wandert die Helligkeit unter den Menuepunkten. Die Grafik
ist unten verankert und beschnitten, deshalb reisst am Rand keine Luecke
auf. Bei "Bewegung reduzieren" bleibt sie stehen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sonne bzw. Mond in der Ecke oben rechts der Leiste, dahinter ziehen
Wolken in zwei Lagen langsam von links nach rechts (reines CSS, nahtlose
Kacheln). Im hellen Modus fallen alle 16-24 s zwei bis drei
Sonnenstrahlen diagonal ueber den Bildschirm. Bernstein bei Tag,
Lavendel und Sterne bei Nacht. Bei prefers-reduced-motion still.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Faehrt gleich nach dem Laden und dann alle vier Minuten abwechselnd
von links und rechts ueber den Boden, raeumt die Schneedecke und schiebt
den Schnee als Wall mit fliegenden Brocken aus dem Fenster.
Nebenbei SnowWeather.tsx wieder im Stil des Projekts formatiert
(einfache Anfuehrungszeichen).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eisblau in hell und dunkel. Flocken fallen per Canvas und sammeln sich
am unteren Fensterrand in einer Hoehenkarte, die per Diffusion
geglaettet wird; nach rund vier Minuten ist die Decke voll (26 px).
Bei prefers-reduced-motion bleibt es still.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Schieferblau in hell und dunkel. Ein paar Tropfen fallen per Canvas
ueber den Bildschirm und zerspritzen am unteren Rand; die Wolken der
oberen Leiste leuchten alle 7-18 s auf (Maske = Wolken-SVG). Bei
prefers-reduced-motion bleibt es still.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neu gefundene Projekte mit Treffer werden einmalig per gpt-5.6-luna mit den
Akquise-Eintraegen und Opportunities der letzten N Tage (Vorgabe 14)
verglichen. Passt ein Eintrag, zeigt der Scanner "bereits beworben!" samt
Begruendung. Schalter und Zeitraum (Tage/Wochen) in Einstellungen -> Scanner,
Neupruefung von Hand im Detail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Der Scroll-Container des Verlaufs (overflow-x-auto) schneidet auch oben ab.
Die Zahl ueber dem hoechsten Balken und die oberste Achsenbeschriftung
ragten darueber hinaus und waren nur halb zu sehen. Innenabstand oben
schafft Platz.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kopier-Symbol auf Kachel, Pipeline-Karte und Listenzeile sowie "Kopieren"
im Dialog. Ein Dialog fragt nach einem oder mehreren Beratern (aus der
Beraterliste der Akquise, ohne den eigenen) und der Phase der Kopie
(Standard: Neu). Neuer Endpoint POST /api/opportunities/:id/copy legt je
Berater eine Kopie mit Projektdaten, Ansprechpartnern, Zustaendigem und
acquisition_id an; Interaktion und naechster Schritt bleiben beim Original.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Im Dialog einer Opportunity aus der Akquise nimmt "Zurueck in die Akquise"
eine versehentliche Uebernahme zurueck: alle Opportunities aus dem Eintrag
wandern in den Papierkorb, der Eintrag steht mit seinem Funddatum wieder in
Eingang bzw. Archiv. Neue Spalte crm_opportunities.acquisition_id, damit bei
mehreren Beratern alle gefunden werden. Wiederherstellen haengt den Eintrag
wieder an die Opportunity.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ServerAlias in beiden Vhosts, HTTP->HTTPS-Redirect fuer beide Namen,
Zertifikat um crm.perinco.de erweitert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
requireAuth hat den Benutzer mit einer ausgeschriebenen Spaltenliste geladen,
in der theme und theme_mode fehlten. GET /api/auth/me antwortete deshalb mit
null, und der ThemeProvider hat das beim Neuladen als "nichts gewaehlt"
gelesen: die Anzeige fiel auf perinco/System zurueck und hat den
localStorage-Zwischenspeicher gleich mit ueberschrieben. Direkt nach dem Klick
und nach dem Anmelden sah es richtig aus, weil PATCH /me den geaenderten Wert
selbst zurueckgibt und der Login SELECT * benutzt - nur das Neuladen nicht.
Dazu ein Test, der die Spaltenliste mit den Feldern von publicUser()
vergleicht, damit ein neues Benutzerfeld nicht wieder still verloren geht.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nur die Beschriftung in der Sidebar; der Ansichtsname 'Interviews' bleibt,
damit Navigation, Typen und Dashboard-Verweise unveraendert funktionieren.
Zwei Hinweistexte, die auf den Menuepunkt zeigen (Termindialog, "Aus Mails"),
sprechen jetzt ebenfalls vom Interviewkalender.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Die Titelspalte hatte truncate ohne feste Breite. In der automatischen
Tabellenbreite hat das nichts abgeschnitten, sondern die Zelle
ausgedehnt - der Titel lief sichtbar in die Spalte "Berater". Jetzt hat
die Spalte eine feste Breite und der Titel bricht um (wrap-anywhere,
damit auch Titel ohne Leerzeichen brechen). Gleiches im Papierkorb.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bisher liess sich eine angelegte Verbindung nur loeschen und neu
anlegen, nur um etwa "Kollege" in "Vorgesetzter" zu drehen. Die Art
steht jetzt als Auswahlfeld in jeder Verbindungszeile - im
Kontaktdialog und in der Seitenleiste der Netzwerkansicht.
Gespeichert wird sofort per PATCH /api/connections/:id, unabhaengig vom
Speichern-Knopf des Formulars: eine Verbindung ist ein eigener
Datensatz und haengt nicht am Kontaktformular, genau wie das Anlegen
und Entfernen daneben. Backend und connectionsApi.update() gab es
bereits, nur die Oberflaeche bot es nirgends an.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anlass: Bei einem Kontakt kam das Foto, beim naechsten nicht. Der Vergleich
der beiden Profilseiten zeigt warum - das zweite Profil liefert als
og:image nur LinkedIns Ersatzgrafik (static.licdn.com/aero-v1/sc/h/...),
weil die Sichtbarkeit des Fotos auf "Meine Kontakte" steht. Angemeldet
sieht man es, der Abruf hier ist es nicht.
Zwei Konsequenzen:
* Die Rueckfallebene "erste media.licdn.com-Adresse im Quelltext" faellt
weg. Dieselbe Seite enthaelt 24 solcher Adressen: Firmenlogos,
Beitragsbilder und die Fotos der "aehnlichen Profile" am Rand. Nur die
Open-Graph-Vorschau gehoert nachweislich zum aufgerufenen Profil -
alles andere haette irgendwann ein fremdes Gesicht an einen Kontakt
gehaengt.
* Die Meldung sagt jetzt, was wirklich los ist: "nur fuer Kontakte
sichtbar oder es gibt keines" statt "hat kein eigenes Foto". Von
aussen sind die beiden Faelle nicht unterscheidbar.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drei Verbesserungen an den Kontakten:
* Beim Anlegen ist der angemeldete Benutzer voreingestellt zustaendig.
Bisher stand dort der erste Benutzer der Liste - fast immer falsch.
* Wird eine LinkedIn-Adresse hinterlegt, holt das CRM das oeffentliche
Profilfoto und setzt es als Kontaktfoto: beim Verlassen des Feldes
einmal je Adresse, solange kein Foto gesetzt ist, und auf Klick ueber
den Knopf daneben. Der Abruf laeuft ueber das Backend
(server/src/linkedinPhoto.js, POST /api/leads/linkedin-photo), weil
ihn die Same-Origin-Policy im Browser verhindert. Gelesen wird die
Open-Graph-Vorschau der Profilseite; Herkunft, Groesse und Antwortzeit
des Bildes werden geprueft, Platzhalter-Silhouetten abgewiesen.
Schlaegt der Abruf fehl, steht der Grund unter dem Feld.
* In der Netzwerkansicht hebt ein Klick auf freie Flaeche die Auswahl
auf - das Diagramm sieht danach aus wie beim Wechsel in die Ansicht.
Ein Ziehen des Hintergrunds (ab 4 px) laesst die Auswahl stehen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Die eigenstaendige Vorgaenger-App unter scanner.perinco.de ist auf dem Server
entfernt: Unit project-scanner-backend (Port 5005), beide Apache-Vhosts, das
Let's-Encrypt-Zertifikat samt Renewal-Config, /var/www/Projekt Scanner (486 MB)
und die Datenbank project_scanner (Dump unter /root/backups). Damit entfallen
die LEGACY_*-Variablen und das Importskript, dessen Quelle es nicht mehr gibt.
/root/.cache/ms-playwright bleibt bewusst stehen: der Cache stammt aus der
Installation der alten App, liegt aber ausserhalb beider Verzeichnisse und wird
vom CRM-Scanner benutzt - playwright ist auf 1.60.0 gepinnt, damit es genau
dieser Build (chromium-1223) bleibt. Der Hinweis steht jetzt in README und
docs/06-betrieb.md, damit ihn beim naechsten Aufraeumen niemand mitnimmt.
SCANNER_SCHEDULER_OFFSET geht auf 0: der Versatz sollte verhindern, dass beide
Instanzen mit denselben Portal-Cookies gleichzeitig losziehen. Ohne zweite
Instanz hat er keinen Zweck mehr, die Mechanik bleibt fuer kuenftige Erststarts
im Code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Im Abschnitt "Verlauf" der Opportunity begann "Was wurde besprochen?"
tiefer als "Letzte Interaktion" daneben. Ursache war die
Beschriftungszeile: rechts steht ein Diktatknopf darin, links nur Text -
die Zeile war damit 26,5 statt 16,5 Pixel hoch und schob das Eingabefeld
nach unten.
Der Knopf bekommt eine feste Hoehe (h-7, 28 px) statt sich aus
Zeilenhoehe, Innenabstand und Rahmen zu ergeben; auf 26,5 px laesst sich
keine Zeile ausrichten. Dazu labelRowClass mit min-h-7: damit ist die
Zeile in beiden Spalten gleich hoch, mit Knopf oder ohne. Gemessen sind
es jetzt 0,0 px Abweichung statt 8.
Zweiter Fund derselben Stelle: die drei Beschriftungen im Dialog waren
von Hand abgeschrieben statt labelClass zu nehmen. Damit fehlte ihnen die
Klasse "field-label", die seit den Themes die Schrift der
Feldbeschriftungen traegt - in "Pixel Art" blieben sie ohne
Pixelschrift. Die Auszeichnung steht jetzt einmal in leadUi.tsx
(labelTextClass) und wird nicht mehr kopiert; DictateTextarea benutzt
sie ebenso.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pixel Art, Outdoor und Cyberpunk legen jetzt eine Grafik hinter die obere
Leiste und die Seitenleiste: 8-bit-Himmel mit Wolken und Huegelkante
sowie eine Steinmauer, blauer Himmel mit Schleierwolken sowie eine Wiese,
Neonroehren im Regen sowie eine regnerische Gasse. perinco bleibt
einfarbig.
Sechs SVG in public/themes/, von Hand gezeichnet - stilisierte Grafik
statt Foto, damit sie in jeder Aufloesung scharf bleibt; zusammen 36 kB.
Die Pixel-Grafiken haben einen absichtlich winzigen viewBox (eine Einheit
= ein Bildpunkt) und shape-rendering="crispEdges", damit die Kanten beim
Hochskalieren hart bleiben; die Mauer blendet nach oben stufenweise aus
und nicht mit einem Verlauf - Pixelgrafik kennt keine Zwischentoene.
Im Markup nur zwei Anker: app-header und app-sidebar. Den Rest machen
zwei Variablen mit Rueckfall auf none, weshalb perinco unberuehrt bleibt.
Die Variable enthaelt zwei Ebenen: einen Schleier aus der Flaechenfarbe
des Themes und darunter die Grafik. Das erledigt zwei Dinge zugleich -
derselbe blaue Himmel wird im Dunkelmodus zur Nacht (keine zweite Datei
noetig), und die Schrift bleibt lesbar, weil der Schleier in der Leiste an
den Raendern dicht liegt und in der Seitenleiste oben ueber den
Menuepunkten.
Aus dem ersten Versuch gelernt: die Cyberpunk-Grafik hatte eine deckende
Nachtflaeche. Im hellen Modus wurde daraus unter dem weissen Schleier ein
grauer Schmutzstreifen. Sie ist jetzt durchsichtig und zeigt nur Licht -
ueber Dunkel leuchtet das Neon, ueber Weiss bleiben blasse farbige
Linien. Ebenso zurueckgenommen wurde der Schleier ueber der Steinmauer:
"Einstellungen" und der Benutzername stehen darauf.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bei der Uebernahme aus der Akquise verschwand die wichtigste Angabe nach
dem Titel: fuer wen das Projekt laeuft. Die Opportunity hatte kein Feld
dafuer, also schrieb die Uebernahme den Namen in den Herkunftsvermerk der
Notizen - in Pipeline, Liste und Kacheln war er damit nicht zu sehen, und
suchen liess sich nur im Notiztext.
crm_opportunities.consultant traegt ihn jetzt selbst. Genau ein Name je
Opportunity: passt ein Fund zu drei Beratern, entstehen bei der
Uebernahme drei Opportunities. Sichtbar in Pipeline-Karte, Kachel,
Papierkorb und als sortierbare Spalte direkt hinter dem Titel; die Suche
fasst ihn mit ein, und im Dialog kommt die Auswahl aus derselben Liste
wie in der Akquise (Einstellungen -> Akquise), damit nicht "Nico" und
"Nico N." nebeneinander entstehen.
Bestehende Opportunities holt migrateOpportunityConsultant() beim Start
aus ihren Notizen nach - nur dort, wo die Spalte leer ist. Damit ist der
Lauf wiederholbar und ueberschreibt nichts, was seither von Hand gesetzt
wurde; ein Merker in crm_settings ist dafuer nicht noetig. Der Text in
den Notizen bleibt stehen: er ist der Herkunftsnachweis samt Anbieter,
Kanal und Link.
Der Namenszusatz im Titel entfaellt wieder. Er war gestern dazugekommen,
damit sich mehrere Opportunities aus einem Fund unterscheiden liessen -
mit einer eigenen Spalte ist er nur noch Rauschen.
Nebenbei: der Block, der won_at nachzieht, stand zweimal in schema.sql.
Idempotent und damit harmlos, aber eine Kopie - jetzt einmal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neon in der Nacht: fast schwarze Flaechen mit blauem Stich, violettes
Leuchten, Blaustahl statt Neutralgrau und Kanten von 2 px - scharf genug
fuer HUD-Elemente, aber nicht eckig, das ist schon Pixel Art. Schatten
sind hier kein Schatten, sondern ein Schein: farbige Kontur plus weicher
Halo in der Markenfarbe. Schrift ist Chakra Petch, die Feldbeschriftungen
laufen gesperrt.
Der Akzent ist bewusst Violett und nicht Neon-Cyan: auf brand-500 und
brand-600 steht 69 Mal weisse Schrift, und auf Cyan waere die nicht zu
lesen. Cyan sitzt deshalb nur in --color-brand-accent und in den
Statusfarben, wo dunkler Text darauf steht.
Der helle Modus ist der Konzern bei Tageslicht: kuehles Blaugrau, dasselbe
Violett nur dunkler, und aus dem Leuchten wird ein knapper kalter Schatten -
ein Halo auf hellem Grund sieht nach Fehler aus, nicht nach Neon.
Es waren vier Zeilen ausserhalb von index.css: die Whitelist in
userFields.js, die Liste in src/types.ts mit ihrer Beschriftung, die
Whitelist im Inline-Skript und die Schrift im Google-Fonts-Aufruf. Keine
Komponente wurde angefasst.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neben dem Design des Hauses ("perinco") gibt es "Pixel Art" und
"Outdoor", jedes in hell und dunkel. Die Wahl steht neben dem
Hell/Dunkel-Schalter im Kopf der App.
Sie gehoert dem BENUTZER und nicht dem Browser: crm_users.theme und
crm_users.theme_mode, gesetzt ueber PATCH /api/auth/me, das jeder fuer
sich selbst darf. Wer sich an einem anderen Rechner anmeldet, findet sein
Design wieder, und zwei Benutzer an derselben Maschine kommen sich nicht
in die Quere. Der localStorage bleibt als Zwischenspeicher daneben, damit
das Inline-Skript in index.html die Klassen vor dem ersten Frame setzen
kann - ohne das blitzt beim Laden das Standarddesign auf. Das hat eine
sichtbare Folge: melden sich zwei Leute an demselben Rechner an, sieht
der zweite fuer einen Augenblick das Design des ersten.
Der ThemeProvider steht deshalb in main.tsx jetzt unter dem AuthProvider
- er liest den Benutzer.
Keine Komponente wurde umgefaerbt. Im Markup steht nirgends eine feste
Farbe, sondern durchgaengig ink-*, brand-*, canvas und surface; Tailwind
v4 loest auch --radius-*, --shadow-* und die Schriften ueber Variablen
auf. Ein Theme tauscht also nur Werte, und deshalb kann Pixel Art alle
380 abgerundeten Ecken eckig machen, ohne eine Klasse anzufassen.
Zwei Dinge waren Voraussetzung:
- Der helle Block eines Themes braucht :not(.dark). html.theme-x hat
eine Klasse mehr in der Spezifitaet als .dark und wuerde den
Dunkelmodus sonst ueberstimmen.
- Auf brand-500/600 steht 69 Mal weisse Schrift. Jedes Theme braucht
dort einen ausreichend dunklen Ton - ein helles Pastell waere
unlesbar.
Die Pixelschrift (Silkscreen) sitzt nur in --font-display, also in den
Ueberschriften und den Feldbeschriftungen; labelClass tragt dafuer die
neue Klasse "field-label". Der Fliesstext bleibt Nunito Sans: die Listen
haben bis zu acht Spalten bei 11-13 px, und eine Pixelschrift waere darin
nicht mehr zu lesen. Outdoor bringt Cabin fuer beides mit.
server/test/userFields.test.mjs prueft die Werte und dass die Liste der
Farbwelten in userFields.js und src/types.ts nicht auseinanderlaeuft -
sonst zeigt die Auswahl ein Design an, dessen Speichern das Backend
abweist, und niemand weiss warum.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Die Wunschliste lag bisher nur lokal im Projektverzeichnis. Sie haelt
fest, was erledigt ist (mit x markiert) und was offen bleibt - unter
anderem der Reminder zum letzten Kontakt, LinkedIn-Bilder bei Kontakten,
das Finden doppelter Ausschreibungen und die Abschlusswahrscheinlichkeit,
die den Betrag nicht aendern darf.
Unveraendert uebernommen, damit die Zuordnung zu den bisherigen Haken
erhalten bleibt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ein gefundenes Projekt passt oft zu mehr als einem Berater, und fuer
jeden geht eine eigene Bewerbung heraus. Das Feld "Berater" liess bisher
nur einen Namen zu.
Gespeichert wird eine kommagetrennte Liste in crm_acquisitions.consultant
(auf VARCHAR(500) erweitert, Migration in db.js) und keine eigene
Zuordnungstabelle: die Namen sind reiner Text, die Auswahlliste in
crm_acquisition_options ist eine Pflegehilfe und kein Fremdschluessel.
Suche, Sortierung und Anzeige der Liste arbeiten damit weiter auf einem
Feld. Die Funktionen zum Zerlegen stehen in acquisitionRules.js, das
Gegenstueck fuer das Frontend in src/types.ts, geprueft von
server/test/acquisition.test.mjs.
Zerlegt wird an vier Stellen:
- Uebernahme: je Berater eine Opportunity, alle in einer Transaktion.
Ihre Titel tragen den Namen in Klammern, weil die Opportunity kein
eigenes Feld fuer den Berater hat und in Pipeline und Liste sonst
identische Zeilen stehen; bei einem einzigen Berater bleibt der Titel
unveraendert. In den Notizen steht neben dem eigenen Berater auch, mit
wem er sich das Projekt teilt. Der Eintrag merkt sich die erste
Opportunity - sie entscheidet nur darueber, ob er wieder im Eingang
auftaucht.
- Verlauf (crm_acquisition_history): ein Berater je Zeile. Ein Eintrag
mit drei Beratern sind drei Bewerbungen, "Nico, Lisa" waere sonst ein
eigener Schluessel neben "Nico" und "Lisa".
- Dashboard: dieselbe Zaehlung. Die Summe der Balken liegt damit ueber
der Gesamtzahl "Bewerbungen", die weiter die Eintraege zaehlt.
- Auswahlliste pflegen: Umbenennen ersetzt den Namen innerhalb der
Liste, Loeschen zaehlt auch, wer nur einer von mehreren ist. Ein
Gleichheitsvergleich uebersah beides.
In der Oberflaeche haengt das Auswahlfeld jeweils einen Namen an, die
Gewaehlten stehen als Plaketten darunter und lassen sich einzeln
entfernen; ein bereits gewaehlter Berater verschwindet aus der Liste.
Bewusst kein <select multiple>: Strg-Klick ist mit der Maus unauffindbar
und mit einer Beruehrung gar nicht bedienbar.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Der Legacy-Import war ein Erststart-Werkzeug: er brach bei vorhandenen
Agenten ab und schrieb immer auch Einstellungen und Portale mit. Um
spaeter einzelne Suchagenten aus project_scanner nachzuziehen, gab es
keinen Weg.
--agents-only laesst Einstellungen, Portale, Projekte und Treffer
unberuehrt und laeuft additiv: vorhandene Agenten blockieren nicht mehr,
sondern werden am Namen erkannt und uebersprungen, damit im CRM
bearbeitete Agenten nicht ueberschrieben werden. Die Id der Quelle wird
nur uebernommen, solange sie im Ziel frei ist.
--skip=Name[,Name] laesst Agenten nach Namen aus, ohne Ruecksicht auf
Gross- und Kleinschreibung.
--force ist mit --agents-only gesperrt: es wuerde Projekte und Treffer
loeschen, die der Lauf gar nicht anfasst.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Loeschen in der Akquise war ein echtes DELETE: der Eintrag stand danach
in keiner Liste, auch nicht im Archiv, und war nicht zurueckholbar. Jetzt
setzt es `deleted_at`/`deleted_by`, die Zeile bleibt 90 Tage stehen.
Der Papierkorb ist die eine Ausnahme von der Regel, dass diese Tabelle
keinen Zustand fuehrt: Loeschen von Hand ist eine Entscheidung, kein
Ablauf. Was dort liegt, blenden alle anderen Abfragen ueber NOT_DELETED
aus - Eingang, Archiv, Menuezahlen und Dashboard. Der Aufraeumlauf ueber
`found_at` laesst geloeschte Zeilen ausdruecklich liegen, sonst waere ein
alter Fund im Papierkorb sofort weg; der Papierkorb raeumt ab
`deleted_at`.
Zwei Rueckwege, deshalb zwei Routen. `/reopen` (bisher `/restore`) holt
aus dem Archiv in den Eingang und setzt das Funddatum auf heute.
`/restore` holt aus dem Papierkorb zurueck und laesst das Funddatum in
Ruhe - der Eintrag soll dort auftauchen, wo er hingehoert, also im
Archiv, wenn er alt ist. Die Meldung nennt das Ziel.
Dazu `DELETE /:id/permanent` und `POST /trash/empty`. Im Papierkorb wird
nicht gepflegt: Bearbeiten und Uebernehmen antworten 409.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Im Archiv setzte der Stift nur `editing` - das Formular stand dort nicht,
also passierte sichtbar nichts. Es wird jetzt auch im Archiv gezeigt,
aber nur beim Bearbeiten: eingetragen wird weiterhin im Eingang.
Dazu ein Weg zurueck. `POST /api/acquisitions/:id/restore` setzt
`found_at` auf heute und leert `converted_at` samt `opportunity_id` -
ohne Letzteres haelt die Uebernahme einen Rueckfaeller im Archiv fest,
gleich welches Datum er traegt. Eine Spalte "wiederhergestellt" gibt es
bewusst nicht; die Tabelle fuehrt keinen Zustand.
Der Pfeil erscheint nur im Archiv. Ein nie uebernommener Eintrag wird
ohne Rueckfrage zurueckgeholt, ein Rueckfaeller mit - dabei faellt der
Verweis auf die geloeschte Opportunity weg. Fuer diese Rueckfrage kennt
`ConfirmDialog` jetzt `tone="neutral"`: ein roter Knopf an einer
harmlosen Stelle nimmt der Farbe ihre Bedeutung.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wer eine Bewerbung uebernimmt, hat den Berater bereits gesprochen. Die
Opportunity in der Phase "Neu" anzulegen bedeutete, sie sofort von Hand
weiterzuschieben. Sie startet jetzt direkt in "Erstgespraech Berater";
Toast, Knopf-Hinweis und Benutzeranleitung nennen dieselbe Phase.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Die Sperre gegen parallele Laeufe schuetzt gegen Ueberholen, nicht gegen
einen Lauf, der gar nicht zurueckkommt. Haengt ein Browser oder steht eine
Verbindung, laeuft das finally nie - pipelineRunning bleibt true und der
Scanner sucht bis zum naechsten Neustart des Dienstes nicht mehr. Das ist
schlimmer als die Ueberlappung, gegen die die Sperre da ist.
Jetzt merkt sich die Pipeline den Startzeitpunkt. Steht die Sperre laenger
als eine Stunde, gibt der naechste Aufruf sie frei und startet neu.
Dokumentiert in docs/03-scanner.md, mit den Messwerten des Vorfalls vom
26.08.2026 in der alten App: fuenf Pipeline-Starts in 16 Minuten, 31
chrome-headless-shell-Prozesse, 1,62 GB RSS, Load Average 126 auf 4 Kernen,
Swap zu 100 Prozent gefuellt. Die alte App hatte die Sperre nicht; der
Guard dort ist mit diesem Commit nachgezogen worden.
Zur Abgrenzung festgehalten: die Chromium-Prozesse waren kein Leck. Alle
Scraper schliessen ihren Browser in einem finally. Es waren fuenf
gleichzeitige Laeufe mal mehrere Boersen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
So wird bei perinco nicht gerechnet: die Summe "Gewichtet" aenderte sich beim
Schieben der Abschlusswahrscheinlichkeit und behauptete damit eine Groesse, die
es im Haus nicht gibt.
Weg sind die Kennzahl ueber der Liste, die Spalte samt Sortierung, die zweite
Zahl in der Vorschau des Formulars und weightedVolume() selbst. Die
Wahrscheinlichkeit bleibt an der Opportunity - Schieberegler im Formular, Prozent
in Liste und Pipeline-Karte; sie geht nur in keine Summe mehr ein.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Die Liste zeigte die zurueckgefallene Bewerbung, der Zaehler am Menue nicht: er
filterte weiter auf "nie uebernommen". Genau der Fehler, den zwei Kopien
derselben Regel machen.
Fristen und Bedingungen stehen jetzt in server/src/acquisitionRules.js und
werden von der Liste, den Zaehlern, dem Aufraeumlauf und der Menuezahl
gemeinsam benutzt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wer eine Opportunity loescht, hat bisher auch die Bewerbung dahinter verloren:
aus der Akquise war sie mit der Uebernahme heraus, und die Opportunity gab es
nicht mehr. Der Eintrag stand nirgends.
Jetzt taucht er im Archiv auf - abgeleitet, nicht gespeichert: `converted_at`
bleibt stehen, sichtbar wird der Eintrag, solange seine Opportunity im
Papierkorb liegt oder endgueltig weg ist. Wird sie zurueckgeholt, verschwindet
der Eintrag von selbst wieder. Es gibt damit keinen Zustand, der nachgezogen
werden muesste und auseinanderlaufen kann.
Die Regel steht als IS_OPEN an einer Stelle und gilt fuer Liste, Zaehler und den
Aufraeumlauf: ein Rueckfaeller altert wie jeder andere Eintrag nach 90 Tagen
heraus, und seine Anzahl wird vorher in den Verlauf gesichert.
Ausserdem:
- Bearbeiten und erneutes Uebernehmen sind wieder erlaubt, sobald die
Opportunity weg ist. Gesperrt bleibt der Eintrag nur, solange sie lebt - dort
wird dann gepflegt.
- Der Eingang zeigt weiter nur, was nie uebernommen wurde.
- Die Zeile traegt ein Kennzeichen ("Opportunity im Papierkorb" bzw.
"Opportunity geloescht"), sonst stuende sie unerklaerlich im Archiv.
- Im Dashboard zaehlt eine Wandlung nur mit lebender Opportunity. Wird sie
geloescht, faellt die Bewerbung zurueck - dann soll die Quote nicht behaupten,
aus ihr sei eine Opportunity geworden.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zwei Sachen, die zusammen wie ein Rechenfehler aussahen:
- Bei der Einteilung "Tag" baute die Achse ihre Schluessel mit toISOString().
Das rechnet nach UTC und liefert fuer den lokalen Tagesanfang in Zeitzonen
oestlich von Greenwich den Vortag - jede Saeule fiel neben die Achse und das
Diagramm blieb leer, obwohl die Kennzahlen Werte zeigten. Die Schluessel kommen
jetzt aus den Ortsdatumsteilen. Monat, Quartal und Jahr waren nie betroffen,
die haben ihre Schluessel schon so gebaut.
- Die Kennzahl "Bewerbungen" zaehlt alle Akquise-Eintraege des Zeitraums, auch
die, aus denen eine Opportunity geworden ist. Die stehen nicht mehr in der
Akquise-Liste, also ist die Zahl regelmaessig hoeher als die Liste lang ist.
Das stand nirgends: jetzt sagt die Fussnote der Kachel, wie viele uebernommen
wurden, und unter den Kacheln steht ein Satz dazu.
Dazu passen bei 30 Tagen jetzt alle Abschnitte nebeneinander (schmalere
Spalten), und passt es doch nicht, zeigt das Diagramm das Ende des Zeitraums -
die aktuellen Tage stehen rechts und interessieren.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>