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:
co-authored by
Claude Opus 5
parent
3c9d83161e
commit
0a2cf52e8a
@@ -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>
|
||||
|
||||
@@ -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' }}
|
||||
>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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" */
|
||||
|
||||
Reference in New Issue
Block a user