From 4fcb34f324a85bc187f97cfd4099802854d525e4 Mon Sep 17 00:00:00 2001 From: Sascha Date: Mon, 14 Sep 2026 16:55:38 +0200 Subject: [PATCH] Quellcode des ICON CRM (5) --- src/components/contacts/ContactModal.tsx | 897 ++++++++++++++++++ src/components/contacts/Contacts.tsx | 523 ++++++++++ .../contacts/PerincoContactModal.tsx | 112 +++ src/components/contacts/contactUi.tsx | 33 + src/components/cv/CvImport.tsx | 537 +++++++++++ src/components/dashboard/Dashboard.tsx | 112 +++ src/components/history/History.tsx | 130 +++ src/components/history/HistoryFeed.tsx | 170 ++++ src/components/leads/LeadModal.tsx | 553 +++++++++++ src/components/leads/Leads.tsx | 369 +++++++ src/components/leads/ReminderWatcher.tsx | 160 ++++ src/components/network/CompanyTree.tsx | 292 ++++++ src/components/network/Network.tsx | 741 +++++++++++++++ src/components/projects/Projects.tsx | 375 ++++++++ src/components/settings/AiSettings.tsx | 231 +++++ src/components/settings/MasterData.tsx | 200 ++++ src/components/settings/Settings.tsx | 253 +++++ src/components/settings/UserManagement.tsx | 269 ++++++ src/components/settings/WhisperSettings.tsx | 250 +++++ src/components/tenders/LiveBoard.tsx | 175 ++++ src/components/tenders/TenderText.tsx | 143 +++ src/components/tenders/Tenders.tsx | 726 ++++++++++++++ src/components/ui/Avatar.tsx | 57 ++ src/components/ui/AvatarPicker.tsx | 111 +++ src/components/ui/Dictate.tsx | 197 ++++ 25 files changed, 7616 insertions(+) create mode 100644 src/components/contacts/ContactModal.tsx create mode 100644 src/components/contacts/Contacts.tsx create mode 100644 src/components/contacts/PerincoContactModal.tsx create mode 100644 src/components/contacts/contactUi.tsx create mode 100644 src/components/cv/CvImport.tsx create mode 100644 src/components/dashboard/Dashboard.tsx create mode 100644 src/components/history/History.tsx create mode 100644 src/components/history/HistoryFeed.tsx create mode 100644 src/components/leads/LeadModal.tsx create mode 100644 src/components/leads/Leads.tsx create mode 100644 src/components/leads/ReminderWatcher.tsx create mode 100644 src/components/network/CompanyTree.tsx create mode 100644 src/components/network/Network.tsx create mode 100644 src/components/projects/Projects.tsx create mode 100644 src/components/settings/AiSettings.tsx create mode 100644 src/components/settings/MasterData.tsx create mode 100644 src/components/settings/Settings.tsx create mode 100644 src/components/settings/UserManagement.tsx create mode 100644 src/components/settings/WhisperSettings.tsx create mode 100644 src/components/tenders/LiveBoard.tsx create mode 100644 src/components/tenders/TenderText.tsx create mode 100644 src/components/tenders/Tenders.tsx create mode 100644 src/components/ui/Avatar.tsx create mode 100644 src/components/ui/AvatarPicker.tsx create mode 100644 src/components/ui/Dictate.tsx diff --git a/src/components/contacts/ContactModal.tsx b/src/components/contacts/ContactModal.tsx new file mode 100644 index 0000000..cf8db45 --- /dev/null +++ b/src/components/contacts/ContactModal.tsx @@ -0,0 +1,897 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + CONTACT_STATUS, + CONTACT_STATE_LABELS, + formatDate, + type Company, + type Contact, + type ContactState, + type DirectoryUser, + type Employment, + type Tristate, +} from '../../types'; +import { contactsApi, getToken, type ContactDetail } from '../../lib/api'; +import { useOptions } from '../../lib/options'; +import { Modal, ConfirmDialog } from '../ui/Modal'; +import { AvatarPicker } from '../ui/AvatarPicker'; +import { Field, Pill, PrimaryButton, SecondaryButton, inputClass, labelClass } from '../ui/form'; +import { MentionField } from '../../lib/mentions'; +import { DictateButton } from '../ui/Dictate'; +import { HistoryFeed } from '../history/HistoryFeed'; +import { historyApi } from '../../lib/api'; +import { useToast } from '../Toast'; +import { useNavigation } from '../../lib/navigation'; +import { useAuth } from '../../lib/auth'; +import { TRISTATE_LABELS } from './contactUi'; + +interface ContactModalProps { + /** null = neuer Kontakt. */ + contactId: number | null; + open: boolean; + onClose: () => void; + onSaved: () => void; + companies: Company[]; + users: DirectoryUser[]; + /** Vorbelegung beim Anlegen aus einer Unternehmenskarte heraus. */ + defaults?: Partial; +} + +type Tab = 'stamm' | 'werdegang' | 'historie'; + +const EMPTY = { + first_name: '', + last_name: '', + position: '', + email: '', + phone: '', + linkedin: '', + company_id: '' as number | '', + kind: 'Experte', + status: 'Interessiert', + lead_source: '', + contact_state: 'unbekannt' as ContactState, + freelancers: 'unbekannt' as Tristate, + bio: '', + tags: '', + rate_onsite: '', + rate_remote: '', + cv_url: '', + owner_id: '' as number | '', + avatar: null as string | null, +}; + +export const ContactModal: React.FC = ({ + contactId, + open, + onClose, + onSaved, + companies, + users, + defaults, +}) => { + const { toast } = useToast(); + const { contactKinds } = useOptions(); + const { user } = useAuth(); + const { open: openEntity } = useNavigation(); + const [tab, setTab] = useState('stamm'); + const [form, setForm] = useState({ ...EMPTY }); + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [interviewDate, setInterviewDate] = useState(''); + const [interviewNote, setInterviewNote] = useState(''); + + const isNew = contactId === null; + + useEffect(() => { + if (!open) return; + setTab('stamm'); + + if (isNew) { + setDetail(null); +/* + * Beim Anlegen ist der angemeldete Benutzer voreingestellt - in aller Regel + * kümmert sich der, der den Datensatz erfasst. Ändern lässt es sich weiterhin. + */ + setForm({ ...EMPTY, owner_id: user?.id ?? '', ...toForm(defaults) }); + return; + } + + let cancelled = false; + setLoading(true); + contactsApi + .detail(contactId) + .then((data) => { + if (cancelled) return; + setDetail(data); + setForm({ ...EMPTY, ...toForm(data.contact) }); + }) + .catch((err) => toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error')) + .finally(() => !cancelled && setLoading(false)); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, contactId]); + + const set = (key: K, value: (typeof EMPTY)[K]) => + setForm((current) => ({ ...current, [key]: value })); + + /* + * Nach einer Aenderung am Werdegang neu laden - und zwar nur die Karte, nicht + * das Formular: was der Benutzer oben eingetippt und noch nicht gespeichert + * hat, darf dabei nicht verschwinden. Ausnahme ist der Arbeitgeber, denn den + * bestimmt die laufende Station. + */ + const reloadDetail = async () => { + if (contactId === null) return; + const data = await contactsApi.detail(contactId); + setDetail(data); + setForm((current) => ({ ...current, company_id: data.contact.company_id ?? '' })); + }; + + const fullName = useMemo( + () => [form.first_name, form.last_name].filter(Boolean).join(' ').trim(), + [form.first_name, form.last_name] + ); + + const save = async () => { + if (!form.last_name.trim()) { + toast('Der Nachname ist erforderlich.', 'error'); + return; + } + setSaving(true); + try { + const body = { + ...form, + company_id: form.company_id === '' ? null : Number(form.company_id), + owner_id: form.owner_id === '' ? null : Number(form.owner_id), + rate_onsite: form.rate_onsite === '' ? null : Number(form.rate_onsite), + rate_remote: form.rate_remote === '' ? null : Number(form.rate_remote), + }; + if (isNew) { + await contactsApi.create(body); + toast(`${fullName || 'Kontakt'} angelegt.`, 'success'); + } else { + await contactsApi.update(contactId, body); + toast('Gespeichert.', 'success'); + } + onSaved(); + onClose(); + } catch (err) { + toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error'); + } finally { + setSaving(false); + } + }; + + const remove = async () => { + if (isNew) return; + try { + await contactsApi.remove(contactId); + toast('Kontakt gelöscht.', 'success'); + onSaved(); + onClose(); + } catch (err) { + toast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error'); + } finally { + setConfirmDelete(false); + } + }; + + const addInterview = async () => { + if (!contactId || !interviewDate) return; + try { + await contactsApi.addInterview(contactId, { held_on: interviewDate, notes: interviewNote || null }); + const data = await contactsApi.detail(contactId); + setDetail(data); + setInterviewDate(''); + setInterviewNote(''); + toast('Interview vermerkt.', 'success'); + } catch (err) { + toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error'); + } + }; + + const addNote = async (text: string) => { + if (!contactId) return; + await historyApi.add('contact', contactId, text); + setDetail(await contactsApi.detail(contactId)); + }; + + const tabs: Array<{ id: Tab; label: string; count?: number }> = [ + { id: 'stamm', label: 'Stammdaten' }, + { id: 'werdegang', label: 'Werdegang & Interviews', count: detail?.employments.length }, + { id: 'historie', label: 'Historie', count: detail?.history.length }, + ]; + + return ( + <> + c.id === Number(form.company_id))?.name] + .filter(Boolean) + .join(' · ') + } + footer={ + <> + {!isNew && ( + + )} + + Abbrechen + + + {saving ? 'Speichern …' : 'Speichern'} + + + } + > + {loading ? ( +

Wird geladen …

+ ) : ( + <> + {!isNew && ( + + )} + + {(isNew || tab === 'stamm') && ( +
+ set('avatar', value)} + linkedin={form.linkedin} + /> + +
+ + set('first_name', event.target.value)} + className={inputClass} + /> + + + set('last_name', event.target.value)} + className={inputClass} + /> + + + set('position', event.target.value)} + className={inputClass} + /> + + + + + + set('email', event.target.value)} + className={inputClass} + /> + + + set('phone', event.target.value)} + className={inputClass} + /> + + + set('linkedin', event.target.value)} + placeholder="https://www.linkedin.com/in/…" + className={inputClass} + /> + +
+ +
+ + + + + + + + + + + + set('lead_source', event.target.value)} + placeholder="LinkedIn, Empfehlung, Portal …" + className={inputClass} + list="lead-sources" + /> + + + + + + + + + +
+ +
+ + set('rate_onsite', event.target.value)} + className={inputClass} + /> + + + set('rate_remote', event.target.value)} + className={inputClass} + /> + + + set('cv_url', event.target.value)} + className={inputClass} + /> + {detail?.contact.cv_file && ( + /* Hochgeladener Lebenslauf aus dem CV-Import. Der Download + braucht den Bearer-Kopf, den ein Link nicht mitschickt. */ + + )} + +
+ + + set('tags', event.target.value)} + placeholder="SAP FI, S/4HANA, Migration" + className={inputClass} + /> + + {form.tags.trim() && ( +
+ {form.tags + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean) + .map((tag) => ( + + {tag} + + ))} +
+ )} + +
+
+ + + set('bio', form.bio.trim() ? `${form.bio.trimEnd()} ${text}` : text) + } + /> +
+ set('bio', value)} rows={4} /> +
+
+ )} + + {!isNew && tab === 'werdegang' && detail && ( +
+ openEntity('company', id)} + /> + +
+

Interviews

+ {detail.interviews.length === 0 ? ( +

Noch kein Interview vermerkt.

+ ) : ( +
    + {detail.interviews.map((entry) => ( +
  • +
    + {formatDate(entry.held_on)} + {entry.company && {entry.company}} + {entry.user_name ?? '–'} +
    + {entry.notes &&

    {entry.notes}

    } +
  • + ))} +
+ )} + +
+ + setInterviewDate(event.target.value)} + className={inputClass} + /> + + + setInterviewNote(event.target.value)} + className={inputClass} + /> + + + Eintragen + +
+
+
+ )} + + {!isNew && tab === 'historie' && detail && ( +
+ + + {detail.mentioned.length > 0 && ( +
+

+ Anderswo genannt ({detail.mentioned.length}) +

+ +
+ )} +
+ )} + + )} +
+ + setConfirmDelete(false)} + /> + + ); +}; + +function toForm(contact: Partial | undefined) { + if (!contact) return {}; + return { + first_name: contact.first_name ?? '', + last_name: contact.last_name ?? '', + position: contact.position ?? '', + email: contact.email ?? '', + phone: contact.phone ?? '', + linkedin: contact.linkedin ?? '', + company_id: (contact.company_id ?? '') as number | '', + kind: contact.kind ?? 'Experte', + status: contact.status ?? 'Interessiert', + lead_source: contact.lead_source ?? '', + contact_state: contact.contact_state ?? 'unbekannt', + freelancers: contact.freelancers ?? 'unbekannt', + bio: contact.bio ?? '', + tags: Array.isArray(contact.tags) ? contact.tags.join(', ') : (contact.tags ?? ''), + rate_onsite: contact.rate_onsite === null || contact.rate_onsite === undefined ? '' : String(contact.rate_onsite), + rate_remote: contact.rate_remote === null || contact.rate_remote === undefined ? '' : String(contact.rate_remote), + cv_url: contact.cv_url ?? '', + owner_id: (contact.owner_id ?? '') as number | '', + avatar: contact.avatar ?? null, + }; +} + +/** + * Den hochgeladenen Lebenslauf holen. + * + * Ueber fetch statt ueber einen Link: die Sitzung haengt am Bearer-Kopf, den + * ein nicht mitschickt. + */ +async function downloadCv(contactId: number, name: string) { + const response = await fetch(`/api/contacts/${contactId}/cv`, { + headers: { Authorization: `Bearer ${getToken() ?? ''}` }, + }); + if (!response.ok) return; + + const disposition = response.headers.get('content-disposition') ?? ''; + const match = /filename="?([^";]+)"?/i.exec(disposition); + const url = URL.createObjectURL(await response.blob()); + const link = document.createElement('a'); + link.href = url; + link.download = match?.[1] || `CV ${name}`; + link.click(); + URL.revokeObjectURL(url); +} + +/* ---------------------------------------------------------------- Werdegang */ + +/** Leere Zeile des Formulars - auch beim Hinzufuegen benutzt. */ +const EMPTY_STATION = { company: '', position: '', started_on: '', ended_on: '', is_current: false }; + +type StationDraft = typeof EMPTY_STATION; + +function toStation(entry: Employment): StationDraft { + return { + company: entry.company ?? '', + position: entry.position ?? '', + started_on: entry.started_on ? entry.started_on.slice(0, 10) : '', + ended_on: entry.ended_on ? entry.ended_on.slice(0, 10) : '', + is_current: entry.is_current === 1, + }; +} + +/** + * Werdegang: anlegen, aendern, entfernen. + * + * Die Stationen entstehen an drei Stellen von selbst - beim Setzen eines + * Arbeitgebers, beim Wechsel und beim CV-Import. Das genuegt nicht: kein + * Lebenslauf wird vollstaendig erkannt, und ein falsch geschriebener Firmenname + * liess sich bisher gar nicht mehr geradeziehen. + * + * Das Unternehmen steht als Textfeld mit Vorschlagsliste da und nicht als + * Auswahlfeld: ein Kunde aus einem Lebenslauf ist haeufig noch nicht im CRM, und + * ihn erst unter Unternehmen anzulegen waere ein Umweg. Ein unbekannter Name + * legt das Unternehmen an - wie beim CV-Import auch. + */ +const Employments: React.FC<{ + contactId: number; + employments: Employment[]; + companies: Company[]; + onChanged: () => Promise; + onOpenCompany: (id: number) => void; +}> = ({ contactId, employments, companies, onChanged, onOpenCompany }) => { + const { toast } = useToast(); + const [editing, setEditing] = useState(null); + const [draft, setDraft] = useState(EMPTY_STATION); + const [adding, setAdding] = useState(false); + const [busy, setBusy] = useState(false); + const [confirmRemove, setConfirmRemove] = useState(null); + + const listId = `companies-${contactId}`; + const setField = (key: K, value: StationDraft[K]) => + setDraft((current) => ({ ...current, [key]: value })); + + const body = () => ({ + company: draft.company.trim(), + position: draft.position.trim(), + started_on: draft.started_on || null, + ended_on: draft.is_current ? null : draft.ended_on || null, + is_current: draft.is_current, + }); + + const startEdit = (entry: Employment) => { + setAdding(false); + setEditing(entry.id); + setDraft(toStation(entry)); + }; + + const startAdd = () => { + setEditing(null); + setAdding(true); + setDraft(EMPTY_STATION); + }; + + const cancel = () => { + setEditing(null); + setAdding(false); + setDraft(EMPTY_STATION); + }; + + const save = async () => { + if (!draft.company.trim()) { + toast('Ohne Unternehmen keine Station.', 'error'); + return; + } + setBusy(true); + try { + if (editing !== null) await contactsApi.updateEmployment(editing, body()); + else await contactsApi.addEmployment(contactId, body()); + await onChanged(); + cancel(); + toast('Station gespeichert.', 'success'); + } catch (err) { + toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error'); + } finally { + setBusy(false); + } + }; + + const remove = async (entry: Employment) => { + setBusy(true); + try { + await contactsApi.removeEmployment(entry.id); + await onChanged(); + toast('Station entfernt.', 'success'); + } catch (err) { + toast(err instanceof Error ? err.message : 'Entfernen fehlgeschlagen.', 'error'); + } finally { + setBusy(false); + setConfirmRemove(null); + } + }; + + const fields = ( +
+ + setField('company', event.target.value)} + className={inputClass} + /> + + + setField('position', event.target.value)} + className={inputClass} + /> + + + setField('started_on', event.target.value)} + className={inputClass} + /> + + + setField('ended_on', event.target.value)} + className={inputClass} + /> + + +
+ void save()} disabled={busy}> + Speichern + + + Abbrechen + +
+
+ ); + + return ( +
+
+

Werdegang

+ {!adding && ( + + Station hinzufügen + + )} +
+ + + {companies.map((company) => ( + + + {employments.length === 0 && !adding ? ( +

+ Noch kein Eintrag. Er entsteht automatisch, sobald ein Unternehmen gesetzt oder gewechselt + wird – oder hier von Hand. +

+ ) : ( +
    + {employments.map((entry) => + editing === entry.id ? ( +
  • + {fields} +
  • + ) : ( +
  • + + {entry.position || '–'} + + {formatDate(entry.started_on)} –{' '} + {entry.is_current ? 'heute' : formatDate(entry.ended_on)} + + {entry.is_current ? ( + aktuell + ) : ( + verlassen + )} + + + + +
  • + ) + )} + {adding &&
  • {fields}
  • } +
+ )} + + {employments.length === 0 && adding && ( +
{fields}
+ )} + + confirmRemove && void remove(confirmRemove)} + onCancel={() => setConfirmRemove(null)} + /> +
+ ); +}; diff --git a/src/components/contacts/Contacts.tsx b/src/components/contacts/Contacts.tsx new file mode 100644 index 0000000..0aae7aa --- /dev/null +++ b/src/components/contacts/Contacts.tsx @@ -0,0 +1,523 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + CONTACT_STATUS, + formatDate, + type Company, + type Contact, + type DirectoryUser, + type PerincoContact, +} from '../../types'; +import { contactsApi, perincoApi } from '../../lib/api'; +import { useOptions } from '../../lib/options'; +import { Avatar } from '../ui/Avatar'; +import { Pill, PrimaryButton, Segmented, inputClass } from '../ui/form'; +import { ContactModal } from './ContactModal'; +import { CvImport } from '../cv/CvImport'; +import { PerincoContactModal } from './PerincoContactModal'; +import { Network } from '../network/Network'; +import { KIND_TONE, STATUS_TONE, formatRate } from './contactUi'; +import { useToast } from '../Toast'; + +interface ContactsProps { + companies: Company[]; + users: DirectoryUser[]; + onChanged: () => void; + /** Von aussen angestossenes Oeffnen, z. B. aus der globalen Suche. */ + openId: number | null; + onOpenHandled: () => void; +} + +type Layout = 'grid' | 'list' | 'network'; + +/** Spalten der Listenansicht, nach denen sich sortieren laesst. */ +type SortKey = 'name' | 'position' | 'company' | 'kind' | 'status' | 'source' | 'updated'; + +const SORT_COLUMNS: Array<{ key: SortKey; label: string; align?: 'right' }> = [ + { key: 'name', label: 'Name' }, + { key: 'position', label: 'Position' }, + { key: 'company', label: 'Unternehmen' }, + { key: 'kind', label: 'Art' }, + { key: 'status', label: 'Status' }, + { key: 'source', label: 'Quelle' }, + { key: 'updated', label: 'Aktualisiert', align: 'right' }, +]; + +/** Der Wert, nach dem eine Spalte sortiert. */ +function sortValue(contact: Contact, key: SortKey): string { + switch (key) { + case 'position': + return contact.position ?? ''; + case 'company': + return contact.company_name ?? ''; + case 'kind': + return contact.kind ?? ''; + case 'status': + return contact.status ?? ''; + case 'source': + return contact.lead_source ?? ''; + case 'updated': + return contact.updated_at ?? ''; + case 'name': + default: + // Nach Nachnamen, wie in der Liste ohnehin gruppiert gelesen wird. + return `${contact.last_name} ${contact.first_name ?? ''}`; + } +} + +/** Eine einzige leere Liste - siehe networkExternals. */ +const EMPTY_PERINCO: PerincoContact[] = []; + +/* + * Kontakte - fruehere Ansicht "Experten". + * + * Der Name ist kein Etikettenwechsel: hier stehen jetzt auch die + * Ansprechpartner der Unternehmen, samt Lead-Quelle. Gefiltert wird ueber die + * Art, nicht ueber zwei getrennte Listen. + */ +export const Contacts: React.FC = ({ + companies, + users, + onChanged, + openId, + onOpenHandled, +}) => { + const { toast } = useToast(); + const { contactKinds } = useOptions(); + const [contacts, setContacts] = useState([]); + const [loading, setLoading] = useState(true); + const [term, setTerm] = useState(''); + const [kind, setKind] = useState(''); + const [status, setStatus] = useState(''); + const [layout, setLayout] = useState( + () => (localStorage.getItem('icon_crm_contacts_layout') as Layout) || 'grid' + ); + /* + * Sortierung der Listenansicht. Ein zweiter Klick auf dieselbe Spalte dreht + * die Richtung um - das ist die Geste, die aus Tabellen bekannt ist. + */ + const [sort, setSort] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({ + key: 'name', + direction: 'asc', + }); + const [editing, setEditing] = useState(undefined); + const [cvOpen, setCvOpen] = useState(false); + + /* + * Kontakte aus dem perinco CRM. Sie werden nur angezeigt, nie gespeichert - + * deshalb ein eigener Zustand neben den eigenen Kontakten und keine + * gemeinsame Liste. Die Wahl bleibt im Browser: sie ist Ansichtssache, nicht + * Teil des Kontos. + */ + const [perincoOn, setPerincoOn] = useState( + () => localStorage.getItem('icon_crm_show_perinco') === '1' + ); + const [perincoAvailable, setPerincoAvailable] = useState(false); + const [perinco, setPerinco] = useState([]); + const [perincoError, setPerincoError] = useState(null); + const [perincoOpen, setPerincoOpen] = useState(null); + + const load = async () => { + setLoading(true); + try { + const { contacts: rows } = await contactsApi.list(); + setContacts(rows); + } catch (err) { + toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (openId === null) return; + setEditing(openId); + onOpenHandled(); + }, [openId, onOpenHandled]); + + useEffect(() => { + localStorage.setItem('icon_crm_contacts_layout', layout); + }, [layout]); + + // Ohne eingerichtete Schnittstelle bleibt der Schalter unsichtbar - ein + // Knopf, der nur eine Fehlermeldung erzeugt, ist keiner. + useEffect(() => { + perincoApi + .status() + .then((status) => setPerincoAvailable(status.configured)) + .catch(() => setPerincoAvailable(false)); + }, []); + + useEffect(() => { + localStorage.setItem('icon_crm_show_perinco', perincoOn ? '1' : '0'); + if (!perincoOn) { + setPerinco([]); + setPerincoError(null); + return; + } + + let cancelled = false; + perincoApi + .contacts() + .then((result) => !cancelled && setPerinco(result.contacts)) + .catch((err) => { + if (cancelled) return; + setPerinco([]); + setPerincoError(err instanceof Error ? err.message : 'perinco CRM nicht erreichbar.'); + }); + return () => { + cancelled = true; + }; + }, [perincoOn]); + + const visiblePerinco = useMemo(() => { + const q = term.trim().toLowerCase(); + return perinco.filter((contact) => { + // Die Arten drüben (Recruiter, Partner, Endkunde) sind andere als hier - + // der Artenfilter darf sie deshalb nicht wegfiltern. + if (kind && contact.kind !== kind) return false; + if (status) return false; + if (!q) return true; + return [contact.name, contact.position, contact.email, contact.company_name, contact.tags.join(' ')] + .filter(Boolean) + .join(' ') + .toLowerCase() + .includes(q); + }); + }, [perinco, term, kind, status]); + + // Gefiltert wird im Browser: die Liste ist klein genug, und so bleibt das + // Tippen ohne Wartezeit. + const visible = useMemo(() => { + const q = term.trim().toLowerCase(); + return contacts.filter((contact) => { + if (kind && contact.kind !== kind) return false; + if (status && contact.status !== status) return false; + if (!q) return true; + return [ + contact.name, + contact.position, + contact.email, + contact.phone, + contact.company_name, + contact.lead_source, + contact.tags.join(' '), + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + .includes(q); + }); + }, [contacts, term, kind, status]); + + /* + * Stabile Referenz fuer die Netzwerkansicht: ein frisch erzeugtes Array + * wuerde dort die Simulation bei jedem Render neu starten. + */ + const networkExternals = useMemo( + () => (perincoOn ? perinco : EMPTY_PERINCO), + [perincoOn, perinco] + ); + + const sorted = useMemo(() => { + const factor = sort.direction === 'asc' ? 1 : -1; + return [...visible].sort((a, b) => { + const left = sortValue(a, sort.key); + const right = sortValue(b, sort.key); + // Leere Felder immer ans Ende - sonst steht oben eine Reihe Striche. + if (!left && right) return 1; + if (left && !right) return -1; + // Datumsangaben vergleichen sich als ISO-Text richtig, Namen brauchen + // die deutsche Sortierung (Umlaute). + return sort.key === 'updated' + ? left.localeCompare(right) * factor + : left.localeCompare(right, 'de', { sensitivity: 'base' }) * factor; + }); + }, [visible, sort]); + + const toggleSort = (key: SortKey) => + setSort((current) => + current.key === key + ? { key, direction: current.direction === 'asc' ? 'desc' : 'asc' } + : { key, direction: key === 'updated' ? 'desc' : 'asc' } + ); + + const afterSave = () => { + void load(); + onChanged(); + }; + + return ( +
+
+ setTerm(event.target.value)} + placeholder="Kontakte filtern …" + className={`${inputClass} max-w-xs`} + /> + + + + + {visible.length} von {contacts.length} + + + + {perincoAvailable && ( + + )} + + setEditing(null)}> + Neuer Kontakt + +
+ + {layout === 'network' ? ( + /* Dieselbe Karte wie bei den Unternehmen - hier lassen sich zusaetzlich + Verbindungen zwischen Kontakten anlegen. */ + + ) : loading ? ( +

Wird geladen …

+ ) : visible.length === 0 && visiblePerinco.length === 0 ? ( +

+ {contacts.length === 0 ? 'Noch keine Kontakte erfasst.' : 'Kein Kontakt passt zum Filter.'} +

+ ) : layout === 'grid' ? ( +
    + {visible.map((contact) => ( +
  • + +
  • + ))} + + {/* Kontakte aus dem perinco CRM: grüner Rahmen, rein lesend. */} + {perincoOn && + visiblePerinco.map((contact) => ( +
  • + +
  • + ))} +
+ ) : ( +
+ + + + {SORT_COLUMNS.map((column) => ( + + ))} + + + + {sorted.map((contact) => ( + setEditing(contact.id)} + className="cursor-pointer hover:bg-brand-50" + > + + + + + + + + + ))} + {perincoOn && + visiblePerinco.map((contact) => ( + setPerincoOpen(contact)} + className="cursor-pointer bg-emerald-50/40 hover:bg-emerald-50" + > + + + + + + + + + ))} + +
+ +
+ + + {contact.name} + + {contact.position || '–'}{contact.company_name || '–'} + {contact.kind} + + {contact.status} + {contact.lead_source || '–'} + {formatDate(contact.updated_at)} +
+ + + {contact.name} + + {contact.position || '–'}{contact.company_name || '–'} + {contact.kind || 'perinco'} + aus perinco{contact.lead_source || '–'} + {formatDate(contact.updated_at)} +
+
+ )} + + {perincoOn && perincoError && ( +

+ perinco CRM: {perincoError} +

+ )} + + {perincoOpen && ( + setPerincoOpen(null)} /> + )} + + {cvOpen && setCvOpen(false)} onImported={afterSave} />} + + {editing !== undefined && ( + setEditing(undefined)} + onSaved={afterSave} + companies={companies} + users={users} + /> + )} +
+ ); +}; diff --git a/src/components/contacts/PerincoContactModal.tsx b/src/components/contacts/PerincoContactModal.tsx new file mode 100644 index 0000000..785bc67 --- /dev/null +++ b/src/components/contacts/PerincoContactModal.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { formatDate, type PerincoContact } from '../../types'; +import { Modal } from '../ui/Modal'; +import { Avatar } from '../ui/Avatar'; +import { Pill, SecondaryButton, labelClass } from '../ui/form'; + +/* + * Ein Kontakt aus dem perinco CRM - rein lesend. + * + * Kein Formular, kein Speichern: gepflegt wird er drüben. Der grüne Rahmen ist + * dasselbe Kennzeichen wie in der Liste, damit auf einen Blick klar ist, wessen + * Datensatz gerade offen ist. + */ +export const PerincoContactModal: React.FC<{ + contact: PerincoContact; + onClose: () => void; +}> = ({ contact, onClose }) => ( + Schließen} + > +
+
+ +
+

{contact.name}

+

{contact.position || '–'}

+

{contact.company_name || '–'}

+
+ aus dem perinco CRM + {contact.kind && {contact.kind}} + {contact.lead_source && {contact.lead_source}} +
+
+
+ +

+ Dieser Kontakt wird im perinco CRM gepflegt und hier nur angezeigt. Änderungen sind deshalb + nur dort möglich. +

+ +
+ +
+ Telefon + {contact.phone ? ( + + {contact.phone} + + ) : ( + – + )} +
+
+ LinkedIn + {contact.linkedin ? ( + + {contact.linkedin} + + ) : ( + – + )} +
+
+ + {contact.tags.length > 0 && ( +
+ Schlagworte +
+ {contact.tags.map((tag) => ( + + {tag} + + ))} +
+
+ )} + + {contact.bio && ( +
+ Notiz +

{contact.bio}

+
+ )} + +

+ Angelegt {formatDate(contact.created_at)} · zuletzt geändert {formatDate(contact.updated_at)} +

+
+ +); diff --git a/src/components/contacts/contactUi.tsx b/src/components/contacts/contactUi.tsx new file mode 100644 index 0000000..1ccf7b4 --- /dev/null +++ b/src/components/contacts/contactUi.tsx @@ -0,0 +1,33 @@ +import type { ContactStatus, ContactKind, Tristate } from '../../types'; + +/** Farbton je Status - dieselbe Zuordnung in Kachel, Liste und Karte. */ +export const STATUS_TONE: Record = { + Interessiert: 'amber', + Interview: 'sky', + Gebucht: 'emerald', + Inaktiv: 'ink', +}; + +export const KIND_TONE: Record = { + Experte: 'brand', + Ansprechpartner: 'violet', + Lead: 'sky', +}; + +export const TRISTATE_LABELS: Record = { + unbekannt: 'Unbekannt', + ja: 'Ja', + nein: 'Nein', +}; + +/* + * "125 €/h". + * + * Es sind Stundensaetze, keine Tagessaetze: die Werte stammen aus den Feldern + * `hourlyRateOnsite` / `hourlyRateRemote` des alten CRM. Die Einheit steht + * deshalb ueberall dabei - 125 liest sich sonst wie ein sehr guenstiger Tag. + */ +export function formatRate(value: number | null | undefined): string { + if (value === null || value === undefined) return '–'; + return `${value.toLocaleString('de-DE', { maximumFractionDigits: 2 })} €/h`; +} diff --git a/src/components/cv/CvImport.tsx b/src/components/cv/CvImport.tsx new file mode 100644 index 0000000..a81b93b --- /dev/null +++ b/src/components/cv/CvImport.tsx @@ -0,0 +1,537 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { cvsApi, getToken, type CvImport as CvRow, type CvSuggestion } from '../../lib/api'; +import { formatStamp } from '../../types'; +import { Modal } from '../ui/Modal'; +import { Field, Pill, PrimaryButton, SecondaryButton, inputClass } from '../ui/form'; +import { useNavigation } from '../../lib/navigation'; +import { compressImageSource } from '../../lib/imageCompress'; +import { useToast } from '../Toast'; + +/* + * CV-Import in drei Ordnern - so, wie es die Featureliste beschreibt. + * + * Eingang hochgeladen, wartet auf die Entscheidung "an die KI, ja oder nein" + * Screening ausgelesen; der Vorschlag steht im Formular und wird bestätigt + * Auswahl übernommen, der Kontakt ist angelegt und der Lebenslauf hängt dran + * + * Es wird nichts automatisch angelegt: ein falsch gelesener Name wäre in der + * Datenbank schwerer zu finden als hier im Formular. + */ + +type Folder = 'eingang' | 'screening' | 'auswahl' | 'verworfen'; + +const FOLDERS: Array<{ id: Folder; label: string }> = [ + { id: 'eingang', label: 'Eingang' }, + { id: 'screening', label: 'Screening' }, + { id: 'auswahl', label: 'Auswahl' }, + { id: 'verworfen', label: 'Verworfen' }, +]; + +export const CvImport: React.FC<{ onClose: () => void; onImported: () => void }> = ({ + onClose, + onImported, +}) => { + const { toast } = useToast(); + const { open } = useNavigation(); + const inputRef = useRef(null); + + const [rows, setRows] = useState([]); + const [ai, setAi] = useState<{ configured: boolean; model: string }>({ configured: false, model: '' }); + const [folder, setFolder] = useState('eingang'); + const [busy, setBusy] = useState(null); + const [uploading, setUploading] = useState(false); + const [dragOver, setDragOver] = useState(false); + const [editing, setEditing] = useState<{ row: CvRow; draft: Draft } | null>(null); + + const load = async () => { + try { + const result = await cvsApi.list(); + setRows(result.imports); + setAi(result.ai); + } catch (err) { + toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error'); + } + }; + + useEffect(() => { + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const upload = async (files: FileList | File[] | null) => { + const list = Array.from(files ?? []); + if (list.length === 0) return; + setUploading(true); + try { + const { count } = await cvsApi.upload(list); + toast(`${count} Datei(en) im Eingang.`, 'success'); + setFolder('eingang'); + await load(); + } catch (err) { + toast(err instanceof Error ? err.message : 'Upload fehlgeschlagen.', 'error'); + } finally { + setUploading(false); + } + }; + + const analyze = async (row: CvRow) => { + setBusy(row.id); + try { + const { extracted, photo } = await cvsApi.analyze(row.id); + await load(); + setFolder('screening'); + setEditing({ + row: { ...row, status: 'screening', extracted, has_photo: Boolean(photo) }, + draft: { ...toDraft(extracted), ...(await photoFields(photo)) }, + }); + } catch (err) { + toast(err instanceof Error ? err.message : 'Auslesen fehlgeschlagen.', 'error'); + await load(); + } finally { + setBusy(null); + } + }; + + /* + * Einen bereits ausgelesenen Lebenslauf oeffnen. Das Foto wird dabei einmal + * verkleinert - nicht erst beim Speichern: so sieht man im Formular genau + * das Bild, das spaeter am Kontakt haengt. + */ + const openScreening = async (row: CvRow) => { + // Erst das Formular zeigen, das Foto danach nachtragen: es einzeln zu holen + // und zu verkleinern dauert einen Wimpernschlag, und solange soll niemand + // vor einem leeren Bildschirm sitzen. + const draft = toDraft(row.extracted!); + setEditing({ row, draft }); + if (!row.has_photo) return; + + try { + const { photo } = await cvsApi.photo(row.id); + const fields = await photoFields(photo); + setEditing((current) => + current && current.row.id === row.id + ? { ...current, draft: { ...current.draft, ...fields } } + : current + ); + } catch { + /* Ohne Foto laesst sich der Kontakt trotzdem anlegen. */ + } + }; + + const reject = async (row: CvRow) => { + await cvsApi.reject(row.id); + await load(); + }; + + const accept = async () => { + if (!editing) return; + setBusy(editing.row.id); + try { + const { contact_id } = await cvsApi.accept(editing.row.id, fromDraft(editing.draft)); + toast('Kontakt angelegt.', 'success'); + setEditing(null); + await load(); + onImported(); + open('contact', contact_id); + onClose(); + } catch (err) { + toast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error'); + } finally { + setBusy(null); + } + }; + + const visible = rows.filter((row) => row.status === folder); + + return ( + <> + Schließen} + > +
{ + event.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={(event) => { + event.preventDefault(); + setDragOver(false); + void upload(event.dataTransfer.files); + }} + className={`mb-5 rounded-xl border-2 border-dashed p-6 text-center transition-colors ${ + dragOver ? 'border-brand-500 bg-brand-50' : 'border-ink-200' + }`} + > +

+ Dateien hierher ziehen – mehrere auf einmal sind möglich. +

+ +

PDF, DOCX oder Text · bis 15 MB je Datei

+ { + void upload(event.target.files); + event.target.value = ''; + }} + /> +
+ + + + {visible.length === 0 ? ( +

Dieser Ordner ist leer.

+ ) : ( +
    + {visible.map((row) => ( +
  • + + + {row.original_name} + + + {formatStamp(row.created_at)} · {Math.round(row.size_bytes / 1024)} kB + {row.uploaded_by_name ? ` · ${row.uploaded_by_name}` : ''} + + {row.error && {row.error}} + + + {row.extracted?.confidence != null && ( + = 0.8 ? 'emerald' : 'amber'}> + {Math.round(row.extracted.confidence * 100)} % + + )} + + { + // Der Download braucht den Bearer-Kopf, den ein normaler + // Link nicht mitschickt - deshalb ueber fetch und Blob. + event.preventDefault(); + void download(row); + }} + > + Datei + + + {row.status === 'eingang' && ( + <> + void analyze(row)} + disabled={busy === row.id || !ai.configured} + className="!px-3 !py-1.5" + > + {busy === row.id ? 'Liest …' : 'Ja, auslesen'} + + + + )} + + {row.status === 'screening' && row.extracted && ( + + void openScreening(row) + } + className="!px-3 !py-1.5" + > + Prüfen & übernehmen + + )} + + {row.status === 'auswahl' && row.contact_id && ( + + )} + + {row.status !== 'auswahl' && ( + + )} +
  • + ))} +
+ )} +
+ + {editing && ( + setEditing(null)} + title="Vorschlag prüfen" + subtitle={`Aus ${editing.row.original_name} – Änderungen hier gewinnen gegen die KI.`} + footer={ + <> + setEditing(null)}>Abbrechen + + {busy !== null ? 'Wird angelegt …' : 'Kontakt anlegen'} + + + } + > + setEditing({ ...editing, draft })} + employments={editing.row.extracted?.employments ?? []} + /> + + )} + + ); +}; + +/* ----------------------------------------------------------------- Formular */ + +interface Draft { + /** Bereits verkleinert (400 px, hoechstens 512 kB) - oder null. */ + avatar: string | null; + /** Haken im Formular: soll das gefundene Foto ueberhaupt mit? */ + useAvatar: boolean; + first_name: string; + last_name: string; + position: string; + email: string; + phone: string; + linkedin: string; + company: string; + tags: string; + bio: string; + rate_onsite: string; + rate_remote: string; +} + +const SuggestionForm: React.FC<{ + draft: Draft; + onChange: (draft: Draft) => void; + employments: CvSuggestion['employments']; +}> = ({ draft, onChange, employments }) => { + const set = (key: keyof Draft, value: string) => onChange({ ...draft, [key]: value }); + + return ( +
+ {/* + Das Foto aus dem Lebenslauf. Es steht oben und nicht unten bei den + Feldern: man erkennt auf einen Blick, ob der Automat das Portrait + erwischt hat oder das Firmenlogo. + */} + {draft.avatar && ( +
+ Gefundenes Foto +
+ +

+ Im Lebenslauf gefunden und auf 400 px verkleinert (höchstens 512 kB). Passt es nicht, + den Haken entfernen – ein anderes Bild lässt sich später in der Kontaktkarte setzen. +

+
+
+ )} + +
+ + set('first_name', e.target.value)} className={inputClass} /> + + + set('last_name', e.target.value)} className={inputClass} /> + + + set('position', e.target.value)} className={inputClass} /> + + + set('company', e.target.value)} className={inputClass} /> + + + set('email', e.target.value)} className={inputClass} /> + + + set('phone', e.target.value)} className={inputClass} /> + + + set('linkedin', e.target.value)} className={inputClass} /> + + + set('rate_onsite', e.target.value)} className={inputClass} /> + + + set('rate_remote', e.target.value)} className={inputClass} /> + +
+ + + set('tags', e.target.value)} className={inputClass} /> + + + +