feat(kontakte): Zustaendigkeit, LinkedIn-Foto, Netzwerkauswahl

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>
This commit is contained in:
Sascha Wolf
2026-09-09 23:25:37 +02:00
co-authored by Claude Opus 5
parent 3c9d83161e
commit 0a2cf52e8a
14 changed files with 540 additions and 26 deletions
+84 -9
View File
@@ -1,8 +1,10 @@
import React, { useMemo, useState } from 'react';
import React, { useMemo, useRef, useState } from 'react';
import { Modal } from '../ui/Modal';
import { AvatarPicker } from '../ui/AvatarPicker';
import { Avatar, inputClass, labelClass } from './leadUi';
import { connectionsApi, leadsApi } from '../../lib/api';
import { useAuth } from '../../lib/auth';
import { compressImageSource } from '../../lib/imageCompress';
import { useToast } from '../Toast';
import {
CONNECTION_TYPES,
@@ -92,10 +94,27 @@ export const LeadModal: React.FC<LeadModalProps> = ({
onCreated,
}) => {
const { addToast } = useToast();
const [form, setForm] = useState<FormState>(() => toFormState(lead, users[0]?.id ?? null));
const { user: currentUser } = useAuth();
/*
* Beim Neuanlegen ist der angemeldete Benutzer zustaendig - das ist in fast
* allen Faellen richtig und laesst sich im Feld darunter aendern. Beim
* Bearbeiten bleibt die gespeicherte Zustaendigkeit unangetastet.
*/
const [form, setForm] = useState<FormState>(() =>
toFormState(lead, currentUser?.id ?? users[0]?.id ?? null)
);
const [tagInput, setTagInput] = useState('');
const [isSaving, setIsSaving] = useState(false);
// LinkedIn-Profilfoto
const [isFetchingPhoto, setIsFetchingPhoto] = useState(false);
const [photoHint, setPhotoHint] = useState<string | null>(null);
/*
* Merkt sich die Adressen, zu denen schon automatisch geholt wurde. Ohne das
* wuerde jeder Klick aus dem Feld heraus einen weiteren Versuch ausloesen.
*/
const triedProfiles = useRef<Set<string>>(new Set());
// Verbindungen dieses Kontakts
const [newConnectionId, setNewConnectionId] = useState('');
const [newConnectionType, setNewConnectionType] = useState<ConnectionType>('Netzwerk');
@@ -134,6 +153,45 @@ export const LeadModal: React.FC<LeadModalProps> = ({
setTagInput('');
};
/**
* Holt das oeffentliche Profilfoto zur hinterlegten LinkedIn-Adresse und legt
* es als Kontaktfoto ins Formular. Automatisch geschieht das nur einmal je
* Adresse und nur, solange noch kein Foto gesetzt ist - ein bewusst
* hochgeladenes Bild wird nie ueberschrieben.
*/
const importLinkedInPhoto = async (profile: string, automatic: boolean) => {
const url = profile.trim();
if (!url || isFetchingPhoto) return;
triedProfiles.current.add(url);
setIsFetchingPhoto(true);
setPhotoHint(null);
try {
const { avatar } = await leadsApi.linkedinPhoto(url);
// Derselbe Weg wie beim Upload: quadratisch zuschneiden und verkleinern.
const compressed = await compressImageSource(avatar);
set('avatar', compressed.dataUrl);
addToast('Profilfoto von LinkedIn übernommen.', 'success');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Das Profilfoto konnte nicht geholt werden.';
setPhotoHint(message);
// Beim automatischen Versuch reicht der Hinweis unter dem Feld; nur der
// ausdrueckliche Klick bekommt eine Meldung.
if (!automatic) addToast(message, 'error');
} finally {
setIsFetchingPhoto(false);
}
};
/** Beim Verlassen des Feldes: einmalig versuchen, wenn noch kein Foto da ist. */
const handleLinkedInBlur = () => {
const url = form.linkedin.trim();
if (!url || form.avatar || triedProfiles.current.has(url)) return;
if (!url.includes('linkedin.com/')) return;
void importLinkedInPhoto(url, true);
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!form.first_name.trim() || !form.last_name.trim()) {
@@ -305,13 +363,30 @@ export const LeadModal: React.FC<LeadModalProps> = ({
</div>
<div>
<label className={labelClass}>LinkedIn</label>
<input
type="url"
placeholder="https://linkedin.com/in/..."
value={form.linkedin}
onChange={(e) => set('linkedin', e.target.value)}
className={inputClass}
/>
<div className="flex gap-2">
<input
type="url"
placeholder="https://linkedin.com/in/..."
value={form.linkedin}
onChange={(e) => set('linkedin', e.target.value)}
onBlur={handleLinkedInBlur}
className={inputClass}
/>
<button
type="button"
onClick={() => importLinkedInPhoto(form.linkedin, false)}
disabled={!form.linkedin.trim() || isFetchingPhoto}
title="Profilfoto von LinkedIn übernehmen"
className="shrink-0 rounded-xl border border-ink-200 px-3 text-xs font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700 disabled:opacity-40"
>
{isFetchingPhoto ? (
<span className="block h-4 w-4 animate-spin rounded-full border-2 border-ink-300 border-t-brand-500" />
) : (
'Foto'
)}
</button>
</div>
{photoHint && <p className="mt-1.5 text-xs font-semibold text-ink-400">{photoHint}</p>}
</div>
<div className="sm:col-span-2">
<label className={labelClass}>Website</label>
+35 -3
View File
@@ -340,7 +340,15 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
return () => element.removeEventListener('wheel', onWheel);
}, []);
const panStateRef = useRef<{ pointerId: number; startX: number; startY: number } | null>(null);
const panStateRef = useRef<{
pointerId: number;
startX: number;
startY: number;
moved: boolean;
} | null>(null);
/** Ab dieser Verschiebung gilt die Geste als Ziehen und nicht mehr als Klick. */
const CLICK_TOLERANCE = 4;
const handleBackgroundPointerDown = (event: React.PointerEvent) => {
if (event.button !== 0) return;
@@ -348,6 +356,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
pointerId: event.pointerId,
startX: event.clientX - transform.x,
startY: event.clientY - transform.y,
moved: false,
};
(event.currentTarget as Element).setPointerCapture(event.pointerId);
};
@@ -355,10 +364,33 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
const handleBackgroundPointerMove = (event: React.PointerEvent) => {
const pan = panStateRef.current;
if (!pan || pan.pointerId !== event.pointerId) return;
setTransform((prev) => ({ ...prev, x: event.clientX - pan.startX, y: event.clientY - pan.startY }));
const x = event.clientX - pan.startX;
const y = event.clientY - pan.startY;
if (Math.abs(x - transform.x) > CLICK_TOLERANCE || Math.abs(y - transform.y) > CLICK_TOLERANCE) {
pan.moved = true;
}
setTransform((prev) => ({ ...prev, x, y }));
};
/*
* Ein Klick ins Leere hebt die Auswahl auf. Damit sieht das Netz wieder so
* aus wie beim Wechsel auf die Ansicht: nichts ist ausgegraut. Ein Ziehen
* (Verschieben der Ansicht) darf die Auswahl dagegen nicht verwerfen,
* deshalb der Vergleich mit der Toleranz. Knoten stoppen das Ereignis
* bereits beim pointerdown, ihre Klicks landen hier gar nicht erst.
*/
const handleBackgroundPointerUp = (event: React.PointerEvent) => {
const pan = panStateRef.current;
if (pan?.pointerId !== event.pointerId) return;
panStateRef.current = null;
if (pan.moved) return;
setSelectedId(null);
setHoveredId(null);
if (linkMode) setLinkSourceId(null);
};
/** Verlaesst der Zeiger die Flaeche, endet die Geste ohne Auswirkung auf die Auswahl. */
const handleBackgroundPointerLeave = (event: React.PointerEvent) => {
if (panStateRef.current?.pointerId === event.pointerId) panStateRef.current = null;
};
@@ -654,7 +686,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
handleNodePointerMove(event);
}}
onPointerUp={handleBackgroundPointerUp}
onPointerLeave={handleBackgroundPointerUp}
onPointerLeave={handleBackgroundPointerLeave}
className="h-full w-full cursor-grab active:cursor-grabbing"
style={{ touchAction: 'none' }}
>
+11
View File
@@ -170,6 +170,17 @@ export const leadsApi = {
request<Lead>(`/leads/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/leads/${id}`, { method: 'DELETE' }),
/**
* Profilfoto zu einer LinkedIn-Adresse holen. Der Server liest das
* oeffentliche Vorschaubild des Profils und liefert es als Data-URL; das
* Speichern uebernimmt danach der normale Kontakt-Aufruf.
*/
linkedinPhoto: (linkedin: string) =>
request<{ avatar: string; bytes: number; source_url: string; profile_url: string }>(
'/leads/linkedin-photo',
{ method: 'POST', body: JSON.stringify({ linkedin }) }
),
/** Herkunftsangaben aller Eintraege aus "Aus Mails", nach Lead-ID. */
mailPoolSources: () => request<Record<string, MailPoolSource>>('/leads/mail-pool/sources'),
+24 -7
View File
@@ -27,22 +27,25 @@ export interface CompressedImage {
type: string;
}
function loadImage(file: File): Promise<HTMLImageElement> {
function loadImageFromUrl(url: string, revoke: boolean): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
if (revoke) URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
if (revoke) URL.revokeObjectURL(url);
reject(new Error('Das Bild konnte nicht gelesen werden.'));
};
image.src = url;
});
}
function loadImage(file: File): Promise<HTMLImageElement> {
return loadImageFromUrl(URL.createObjectURL(file), true);
}
/** Laenge einer Data-URL in Bytes (Base64 traegt 4 Zeichen pro 3 Bytes). */
function dataUrlBytes(dataUrl: string): number {
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
@@ -59,7 +62,21 @@ export async function compressImage(file: File): Promise<CompressedImage> {
throw new Error('Die Datei ist zu groß (max. 25 MB).');
}
const image = await loadImage(file);
return renderToSquare(await loadImage(file), file.size);
}
/**
* Wie compressImage(), nur fuer ein bereits geladenes Bild als Data-URL - etwa
* das Profilfoto, das der Server von LinkedIn geholt hat. Der Weg ueber den
* Canvas ist derselbe, damit ein importiertes Foto exakt so aussieht wie ein
* hochgeladenes.
*/
export async function compressImageSource(dataUrl: string): Promise<CompressedImage> {
const image = await loadImageFromUrl(dataUrl, false);
return renderToSquare(image, dataUrlBytes(dataUrl));
}
function renderToSquare(image: HTMLImageElement, sourceBytes: number): CompressedImage {
const { naturalWidth: width, naturalHeight: height } = image;
if (!width || !height) throw new Error('Das Bild hat keine lesbaren Abmessungen.');
@@ -83,7 +100,7 @@ export async function compressImage(file: File): Promise<CompressedImage> {
if (dataUrl.startsWith(`data:${candidate.type}`)) {
return {
dataUrl,
sourceBytes: file.size,
sourceBytes,
resultBytes: dataUrlBytes(dataUrl),
type: candidate.type,
};
@@ -91,7 +108,7 @@ export async function compressImage(file: File): Promise<CompressedImage> {
}
const png = canvas.toDataURL('image/png');
return { dataUrl: png, sourceBytes: file.size, resultBytes: dataUrlBytes(png), type: 'image/png' };
return { dataUrl: png, sourceBytes, resultBytes: dataUrlBytes(png), type: 'image/png' };
}
/** "2,4 MB" bzw. "18 KB" */