Quellcode des ICON CRM (5)

This commit is contained in:
2026-09-14 16:55:38 +02:00
parent e383400d7c
commit 4fcb34f324
25 changed files with 7616 additions and 0 deletions
+897
View File
@@ -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<Contact>;
}
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<ContactModalProps> = ({
contactId,
open,
onClose,
onSaved,
companies,
users,
defaults,
}) => {
const { toast } = useToast();
const { contactKinds } = useOptions();
const { user } = useAuth();
const { open: openEntity } = useNavigation();
const [tab, setTab] = useState<Tab>('stamm');
const [form, setForm] = useState({ ...EMPTY });
const [detail, setDetail] = useState<ContactDetail | null>(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 = <K extends keyof typeof EMPTY>(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 (
<>
<Modal
open={open}
onClose={onClose}
size="lg"
title={isNew ? 'Neuer Kontakt' : fullName || 'Kontakt'}
subtitle={
isNew
? 'Experten und Ansprechpartner liegen in derselben Liste.'
: [form.position, companies.find((c) => c.id === Number(form.company_id))?.name]
.filter(Boolean)
.join(' · ')
}
footer={
<>
{!isNew && (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="mr-auto rounded-lg px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
>
Löschen
</button>
)}
<SecondaryButton type="button" onClick={onClose}>
Abbrechen
</SecondaryButton>
<PrimaryButton type="button" onClick={save} disabled={saving}>
{saving ? 'Speichern …' : 'Speichern'}
</PrimaryButton>
</>
}
>
{loading ? (
<p className="py-10 text-center text-sm text-ink-400">Wird geladen …</p>
) : (
<>
{!isNew && (
<nav className="mb-5 flex gap-1 border-b border-ink-200">
{tabs.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setTab(item.id)}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
tab === item.id
? 'border-brand-500 text-brand-700'
: 'border-transparent text-ink-500 hover:text-ink-800'
}`}
>
{item.label}
{item.count !== undefined && item.count > 0 && (
<span className="ml-1.5 text-xs text-ink-400">{item.count}</span>
)}
</button>
))}
</nav>
)}
{(isNew || tab === 'stamm') && (
<div className="space-y-5">
<AvatarPicker
name={fullName || 'Kontakt'}
value={form.avatar}
onChange={(value) => set('avatar', value)}
linkedin={form.linkedin}
/>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Vorname">
<input
value={form.first_name}
onChange={(event) => set('first_name', event.target.value)}
className={inputClass}
/>
</Field>
<Field label="Nachname *">
<input
value={form.last_name}
onChange={(event) => set('last_name', event.target.value)}
className={inputClass}
/>
</Field>
<Field label="Position">
<input
value={form.position}
onChange={(event) => set('position', event.target.value)}
className={inputClass}
/>
</Field>
<Field label="Unternehmen">
<select
value={form.company_id}
onChange={(event) =>
set('company_id', event.target.value === '' ? '' : Number(event.target.value))
}
className={inputClass}
>
<option value="">– kein Unternehmen –</option>
{companies.map((company) => (
<option key={company.id} value={company.id}>
{company.name}
</option>
))}
</select>
</Field>
<Field label="E-Mail">
<input
type="email"
value={form.email}
onChange={(event) => set('email', event.target.value)}
className={inputClass}
/>
</Field>
<Field label="Telefon">
<input
value={form.phone}
onChange={(event) => set('phone', event.target.value)}
className={inputClass}
/>
</Field>
<Field
label="LinkedIn"
hint="Mit hinterlegter Adresse lässt sich das Profilfoto oben automatisch holen."
className="sm:col-span-2"
>
<input
value={form.linkedin}
onChange={(event) => set('linkedin', event.target.value)}
placeholder="https://www.linkedin.com/in/…"
className={inputClass}
/>
</Field>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Field label="Art">
<select
value={form.kind}
onChange={(event) => set('kind', event.target.value)}
className={inputClass}
>
{contactKinds.map((kind) => (
<option key={kind}>{kind}</option>
))}
</select>
</Field>
<Field label="Status">
<select
value={form.status}
onChange={(event) => set('status', event.target.value)}
className={inputClass}
>
{CONTACT_STATUS.map((status) => (
<option key={status}>{status}</option>
))}
</select>
</Field>
<Field label="Zuständig">
<select
value={form.owner_id}
onChange={(event) =>
set('owner_id', event.target.value === '' ? '' : Number(event.target.value))
}
className={inputClass}
>
<option value="">– niemand –</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</Field>
<Field label="Lead-Quelle" hint="Woher kommt der Kontakt?">
<input
value={form.lead_source}
onChange={(event) => set('lead_source', event.target.value)}
placeholder="LinkedIn, Empfehlung, Portal …"
className={inputClass}
list="lead-sources"
/>
<datalist id="lead-sources">
<option value="LinkedIn" />
<option value="XING" />
<option value="Empfehlung" />
<option value="Portal" />
<option value="Messe" />
<option value="Kaltakquise" />
</datalist>
</Field>
<Field label="Kontaktstand">
<select
value={form.contact_state}
onChange={(event) => set('contact_state', event.target.value as ContactState)}
className={inputClass}
>
{(Object.keys(CONTACT_STATE_LABELS) as ContactState[]).map((state) => (
<option key={state} value={state}>
{CONTACT_STATE_LABELS[state]}
</option>
))}
</select>
</Field>
<Field label="Arbeitet mit Freelancern">
<select
value={form.freelancers}
onChange={(event) => set('freelancers', event.target.value as Tristate)}
className={inputClass}
>
{(Object.keys(TRISTATE_LABELS) as Tristate[]).map((state) => (
<option key={state} value={state}>
{TRISTATE_LABELS[state]}
</option>
))}
</select>
</Field>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Field label="Stundensatz vor Ort (€/h)">
<input
type="number"
value={form.rate_onsite}
onChange={(event) => set('rate_onsite', event.target.value)}
className={inputClass}
/>
</Field>
<Field label="Stundensatz remote (€/h)">
<input
type="number"
value={form.rate_remote}
onChange={(event) => set('rate_remote', event.target.value)}
className={inputClass}
/>
</Field>
<Field label="CV (Link)" hint="OneDrive, SharePoint oder öffentliche Adresse.">
<input
value={form.cv_url}
onChange={(event) => 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. */
<button
type="button"
onClick={() => void downloadCv(contactId as number, fullName)}
className="mt-1 text-xs font-medium text-brand-700 hover:underline"
>
Hochgeladenen Lebenslauf herunterladen
</button>
)}
</Field>
</div>
<Field
label="Schlagworte"
hint="Kommagetrennt. Beim Speichern werden daraus einzelne Chips."
>
<input
value={form.tags}
onChange={(event) => set('tags', event.target.value)}
placeholder="SAP FI, S/4HANA, Migration"
className={inputClass}
/>
</Field>
{form.tags.trim() && (
<div className="flex flex-wrap gap-1.5">
{form.tags
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
.map((tag) => (
<Pill key={tag} tone="brand">
{tag}
</Pill>
))}
</div>
)}
<div>
<div className="mb-1 flex items-center justify-between gap-2">
<label className={`${labelClass} mb-0`}>Notiz</label>
<DictateButton
label="Notiz"
compact
onBeforeNavigate={onClose}
onText={(text) =>
set('bio', form.bio.trim() ? `${form.bio.trimEnd()} ${text}` : text)
}
/>
</div>
<MentionField value={form.bio} onChange={(value) => set('bio', value)} rows={4} />
</div>
</div>
)}
{!isNew && tab === 'werdegang' && detail && (
<div className="space-y-6">
<Employments
contactId={contactId!}
employments={detail.employments}
companies={companies}
onChanged={reloadDetail}
onOpenCompany={(id) => openEntity('company', id)}
/>
<section>
<h3 className="mb-2 text-sm font-semibold text-ink-900">Interviews</h3>
{detail.interviews.length === 0 ? (
<p className="text-sm text-ink-400">Noch kein Interview vermerkt.</p>
) : (
<ul className="mb-3 divide-y divide-ink-100 rounded-lg border border-ink-200">
{detail.interviews.map((entry) => (
<li key={entry.id} className="px-3 py-2.5">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-ink-900">{formatDate(entry.held_on)}</span>
{entry.company && <span className="text-ink-500">{entry.company}</span>}
<span className="ml-auto text-xs text-ink-400">{entry.user_name ?? '–'}</span>
</div>
{entry.notes && <p className="mt-1 text-sm text-ink-600">{entry.notes}</p>}
</li>
))}
</ul>
)}
<div className="flex flex-wrap items-end gap-2">
<Field label="Datum">
<input
type="date"
value={interviewDate}
onChange={(event) => setInterviewDate(event.target.value)}
className={inputClass}
/>
</Field>
<Field label="Notiz" className="min-w-[14rem] flex-1">
<input
value={interviewNote}
onChange={(event) => setInterviewNote(event.target.value)}
className={inputClass}
/>
</Field>
<PrimaryButton type="button" onClick={addInterview} disabled={!interviewDate}>
Eintragen
</PrimaryButton>
</div>
</section>
</div>
)}
{!isNew && tab === 'historie' && detail && (
<div className="space-y-6">
<HistoryFeed entries={detail.history} onAdd={addNote} />
{detail.mentioned.length > 0 && (
<section>
<h3 className="mb-2 text-sm font-semibold text-ink-900">
Anderswo genannt ({detail.mentioned.length})
</h3>
<HistoryFeed entries={detail.mentioned} searchable={false} showEntity />
</section>
)}
</div>
)}
</>
)}
</Modal>
<ConfirmDialog
open={confirmDelete}
title="Kontakt löschen"
message={`${fullName || 'Dieser Kontakt'} wird endgültig gelöscht. Die Historie bleibt erhalten.`}
onConfirm={remove}
onCancel={() => setConfirmDelete(false)}
/>
</>
);
};
function toForm(contact: Partial<Contact> | 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 <a href> 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<void>;
onOpenCompany: (id: number) => void;
}> = ({ contactId, employments, companies, onChanged, onOpenCompany }) => {
const { toast } = useToast();
const [editing, setEditing] = useState<number | null>(null);
const [draft, setDraft] = useState<StationDraft>(EMPTY_STATION);
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState(false);
const [confirmRemove, setConfirmRemove] = useState<Employment | null>(null);
const listId = `companies-${contactId}`;
const setField = <K extends keyof StationDraft>(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 = (
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Unternehmen *" hint="Unbekannte Namen werden als Unternehmen angelegt.">
<input
list={listId}
value={draft.company}
onChange={(event) => setField('company', event.target.value)}
className={inputClass}
/>
</Field>
<Field label="Position">
<input
value={draft.position}
onChange={(event) => setField('position', event.target.value)}
className={inputClass}
/>
</Field>
<Field label="Von">
<input
type="date"
value={draft.started_on}
onChange={(event) => setField('started_on', event.target.value)}
className={inputClass}
/>
</Field>
<Field label="Bis" hint={draft.is_current ? 'Läuft noch – kein Enddatum.' : undefined}>
<input
type="date"
value={draft.ended_on}
disabled={draft.is_current}
onChange={(event) => setField('ended_on', event.target.value)}
className={inputClass}
/>
</Field>
<label className="flex items-center gap-2 text-sm text-ink-700 sm:col-span-2">
<input
type="checkbox"
checked={draft.is_current}
onChange={(event) => setField('is_current', event.target.checked)}
className="h-4 w-4 accent-[var(--color-brand-600)]"
/>
Aktuelle Station – setzt zugleich den Arbeitgeber des Kontakts
</label>
<div className="flex gap-2 sm:col-span-2">
<PrimaryButton type="button" onClick={() => void save()} disabled={busy}>
Speichern
</PrimaryButton>
<SecondaryButton type="button" onClick={cancel} disabled={busy}>
Abbrechen
</SecondaryButton>
</div>
</div>
);
return (
<section>
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-ink-900">Werdegang</h3>
{!adding && (
<SecondaryButton type="button" onClick={startAdd} className="px-3 py-1 text-xs">
Station hinzufügen
</SecondaryButton>
)}
</div>
<datalist id={listId}>
{companies.map((company) => (
<option key={company.id} value={company.name} />
))}
</datalist>
{employments.length === 0 && !adding ? (
<p className="text-sm text-ink-400">
Noch kein Eintrag. Er entsteht automatisch, sobald ein Unternehmen gesetzt oder gewechselt
wird – oder hier von Hand.
</p>
) : (
<ul className="divide-y divide-ink-100 rounded-lg border border-ink-200">
{employments.map((entry) =>
editing === entry.id ? (
<li key={entry.id} className="bg-ink-50 px-3 py-3">
{fields}
</li>
) : (
<li key={entry.id} className="flex flex-wrap items-center gap-3 px-3 py-2.5">
<button
type="button"
onClick={() => onOpenCompany(entry.company_id)}
className="text-sm font-medium text-brand-700 hover:underline"
>
{entry.company}
</button>
<span className="text-xs text-ink-400">{entry.position || '–'}</span>
<span className="ml-auto text-xs text-ink-400">
{formatDate(entry.started_on)} –{' '}
{entry.is_current ? 'heute' : formatDate(entry.ended_on)}
</span>
{entry.is_current ? (
<Pill tone="emerald">aktuell</Pill>
) : (
<Pill tone="amber">verlassen</Pill>
)}
<span className="flex items-center gap-0.5">
<button
type="button"
title="Station bearbeiten"
aria-label="Station bearbeiten"
onClick={() => startEdit(entry)}
className="rounded p-1 text-ink-400 hover:bg-ink-100 hover:text-brand-700"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5Z" />
</svg>
</button>
<button
type="button"
title="Station entfernen"
aria-label="Station entfernen"
onClick={() => setConfirmRemove(entry)}
className="rounded p-1 text-red-500 hover:bg-red-50 hover:text-red-600"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 7h12M9 7V5h6v2m-8 0 1 13h8l1-13" />
</svg>
</button>
</span>
</li>
)
)}
{adding && <li className="bg-ink-50 px-3 py-3">{fields}</li>}
</ul>
)}
{employments.length === 0 && adding && (
<div className="mt-2 rounded-lg border border-ink-200 bg-ink-50 px-3 py-3">{fields}</div>
)}
<ConfirmDialog
open={confirmRemove !== null}
title="Station entfernen"
message={
confirmRemove?.is_current
? `"${confirmRemove?.company}" wird aus dem Werdegang entfernt. Da es die laufende Station ist, hat der Kontakt danach keinen Arbeitgeber mehr.`
: `"${confirmRemove?.company}" wird aus dem Werdegang entfernt.`
}
confirmLabel="Entfernen"
onConfirm={() => confirmRemove && void remove(confirmRemove)}
onCancel={() => setConfirmRemove(null)}
/>
</section>
);
};
+523
View File
@@ -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<ContactsProps> = ({
companies,
users,
onChanged,
openId,
onOpenHandled,
}) => {
const { toast } = useToast();
const { contactKinds } = useOptions();
const [contacts, setContacts] = useState<Contact[]>([]);
const [loading, setLoading] = useState(true);
const [term, setTerm] = useState('');
const [kind, setKind] = useState('');
const [status, setStatus] = useState('');
const [layout, setLayout] = useState<Layout>(
() => (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<number | null | undefined>(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<PerincoContact[]>([]);
const [perincoError, setPerincoError] = useState<string | null>(null);
const [perincoOpen, setPerincoOpen] = useState<PerincoContact | null>(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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder="Kontakte filtern …"
className={`${inputClass} max-w-xs`}
/>
<select value={kind} onChange={(event) => setKind(event.target.value)} className={`${inputClass} w-auto`}>
<option value="">Alle Arten</option>
{contactKinds.map((value) => (
<option key={value}>{value}</option>
))}
</select>
<select
value={status}
onChange={(event) => setStatus(event.target.value)}
className={`${inputClass} w-auto`}
>
<option value="">Alle Status</option>
{CONTACT_STATUS.map((value) => (
<option key={value}>{value}</option>
))}
</select>
<span className="ml-auto text-sm text-ink-400">
{visible.length} von {contacts.length}
</span>
<Segmented
value={layout}
onChange={setLayout}
options={[
{ value: 'grid', label: 'Kacheln' },
{ value: 'list', label: 'Liste' },
{ value: 'network', label: 'Netzwerk', title: 'Wer kennt wen, wer arbeitet wo' },
]}
/>
{perincoAvailable && (
<label
title="Kontakte aus dem perinco CRM zusätzlich anzeigen – lesend, grün umrandet"
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium ${
perincoOn
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: 'border-ink-200 text-ink-600 hover:bg-ink-100'
}`}
>
<input
type="checkbox"
checked={perincoOn}
onChange={(event) => setPerincoOn(event.target.checked)}
className="h-4 w-4 accent-emerald-600"
/>
perinco
{perincoOn && perinco.length > 0 && (
<span className="text-xs text-emerald-700">{visiblePerinco.length}</span>
)}
</label>
)}
<button
type="button"
onClick={() => setCvOpen(true)}
title="Lebensläufe hochladen und von der KI auslesen lassen"
className="rounded-lg border border-ink-200 px-3 py-2 text-sm font-medium text-ink-700 hover:bg-ink-100"
>
CV-Import
</button>
<PrimaryButton type="button" onClick={() => setEditing(null)}>
Neuer Kontakt
</PrimaryButton>
</div>
{layout === 'network' ? (
/* Dieselbe Karte wie bei den Unternehmen - hier lassen sich zusaetzlich
Verbindungen zwischen Kontakten anlegen. */
<Network mode="contacts" term={term} externals={networkExternals} />
) : loading ? (
<p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>
) : visible.length === 0 && visiblePerinco.length === 0 ? (
<p className="py-16 text-center text-sm text-ink-400">
{contacts.length === 0 ? 'Noch keine Kontakte erfasst.' : 'Kein Kontakt passt zum Filter.'}
</p>
) : layout === 'grid' ? (
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{visible.map((contact) => (
<li key={contact.id}>
<button
type="button"
onClick={() => setEditing(contact.id)}
className="flex h-full w-full flex-col gap-3 rounded-xl border border-ink-200 bg-surface p-4 text-left shadow-card transition-shadow hover:shadow-lift"
>
<div className="flex items-start gap-3">
<Avatar name={contact.name} src={contact.avatar} size={48} />
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-ink-900">{contact.name}</p>
<p className="truncate text-sm text-ink-500">{contact.position || '–'}</p>
{contact.company_name && (
<p className="truncate text-xs text-ink-400">{contact.company_name}</p>
)}
</div>
</div>
<div className="flex flex-wrap gap-1.5">
<Pill tone={KIND_TONE[contact.kind]}>{contact.kind}</Pill>
<Pill tone={STATUS_TONE[contact.status]}>{contact.status}</Pill>
{contact.lead_source && <Pill>{contact.lead_source}</Pill>}
{(contact.interview_count ?? 0) > 0 && (
<Pill tone="violet">{contact.interview_count} Interview(s)</Pill>
)}
</div>
{contact.tags.length > 0 && (
<p className="line-clamp-2 text-xs text-ink-400">{contact.tags.join(' · ')}</p>
)}
<div className="mt-auto flex items-center justify-between pt-1 text-xs text-ink-400">
<span className="truncate">{contact.email || contact.phone || '–'}</span>
<span>{formatRate(contact.rate_remote)}</span>
</div>
</button>
</li>
))}
{/* Kontakte aus dem perinco CRM: grüner Rahmen, rein lesend. */}
{perincoOn &&
visiblePerinco.map((contact) => (
<li key={contact.id}>
<button
type="button"
onClick={() => setPerincoOpen(contact)}
className="flex h-full w-full flex-col gap-3 rounded-xl border-2 border-emerald-500 bg-surface p-4 text-left shadow-card transition-shadow hover:shadow-lift"
>
<div className="flex items-start gap-3">
<Avatar name={contact.name} src={contact.avatar} size={48} />
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-ink-900">{contact.name}</p>
<p className="truncate text-sm text-ink-500">{contact.position || '–'}</p>
{contact.company_name && (
<p className="truncate text-xs text-ink-400">{contact.company_name}</p>
)}
</div>
</div>
<div className="flex flex-wrap gap-1.5">
<Pill tone="emerald">perinco</Pill>
{contact.kind && <Pill>{contact.kind}</Pill>}
{contact.lead_source && <Pill>{contact.lead_source}</Pill>}
</div>
{contact.tags.length > 0 && (
<p className="line-clamp-2 text-xs text-ink-400">{contact.tags.join(' · ')}</p>
)}
<div className="mt-auto pt-1 text-xs text-ink-400">
<span className="truncate">{contact.email || contact.phone || '–'}</span>
</div>
</button>
</li>
))}
</ul>
) : (
<div className="overflow-x-auto rounded-xl border border-ink-200 bg-surface">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-ink-200 text-left text-xs uppercase tracking-wide text-ink-500">
{SORT_COLUMNS.map((column) => (
<th
key={column.key}
className={`px-4 py-2.5 font-semibold ${column.align === 'right' ? 'text-right' : ''}`}
>
<button
type="button"
onClick={() => toggleSort(column.key)}
title={`Nach ${column.label} sortieren`}
className={`inline-flex items-center gap-1 uppercase tracking-wide hover:text-ink-800 ${
sort.key === column.key ? 'text-brand-700' : ''
}`}
>
{column.label}
<span aria-hidden="true" className="text-[10px]">
{sort.key === column.key ? (sort.direction === 'asc' ? '▲' : '▼') : '↕'}
</span>
</button>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{sorted.map((contact) => (
<tr
key={contact.id}
onClick={() => setEditing(contact.id)}
className="cursor-pointer hover:bg-brand-50"
>
<td className="px-4 py-2.5">
<span className="flex items-center gap-2">
<Avatar name={contact.name} src={contact.avatar} size={28} />
<span className="font-medium text-ink-900">{contact.name}</span>
</span>
</td>
<td className="px-4 py-2.5 text-ink-600">{contact.position || '–'}</td>
<td className="px-4 py-2.5 text-ink-600">{contact.company_name || '–'}</td>
<td className="px-4 py-2.5">
<Pill tone={KIND_TONE[contact.kind]}>{contact.kind}</Pill>
</td>
<td className="px-4 py-2.5">
<Pill tone={STATUS_TONE[contact.status]}>{contact.status}</Pill>
</td>
<td className="px-4 py-2.5 text-ink-500">{contact.lead_source || '–'}</td>
<td className="px-4 py-2.5 text-right text-xs text-ink-400">
{formatDate(contact.updated_at)}
</td>
</tr>
))}
{perincoOn &&
visiblePerinco.map((contact) => (
<tr
key={contact.id}
onClick={() => setPerincoOpen(contact)}
className="cursor-pointer bg-emerald-50/40 hover:bg-emerald-50"
>
<td className="border-l-4 border-emerald-500 px-4 py-2.5">
<span className="flex items-center gap-2">
<Avatar name={contact.name} src={contact.avatar} size={28} />
<span className="font-medium text-ink-900">{contact.name}</span>
</span>
</td>
<td className="px-4 py-2.5 text-ink-600">{contact.position || '–'}</td>
<td className="px-4 py-2.5 text-ink-600">{contact.company_name || '–'}</td>
<td className="px-4 py-2.5">
<Pill tone="emerald">{contact.kind || 'perinco'}</Pill>
</td>
<td className="px-4 py-2.5 text-xs text-ink-400">aus perinco</td>
<td className="px-4 py-2.5 text-ink-500">{contact.lead_source || '–'}</td>
<td className="px-4 py-2.5 text-right text-xs text-ink-400">
{formatDate(contact.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{perincoOn && perincoError && (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
perinco CRM: {perincoError}
</p>
)}
{perincoOpen && (
<PerincoContactModal contact={perincoOpen} onClose={() => setPerincoOpen(null)} />
)}
{cvOpen && <CvImport onClose={() => setCvOpen(false)} onImported={afterSave} />}
{editing !== undefined && (
<ContactModal
open
contactId={editing}
onClose={() => setEditing(undefined)}
onSaved={afterSave}
companies={companies}
users={users}
/>
)}
</div>
);
};
@@ -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 }) => (
<Modal
open
size="md"
onClose={onClose}
title={contact.name}
subtitle={[contact.position, contact.company_name].filter(Boolean).join(' · ')}
footer={<SecondaryButton onClick={onClose}>Schließen</SecondaryButton>}
>
<div className="space-y-5">
<div className="flex items-start gap-4 rounded-lg border-2 border-emerald-500 bg-emerald-50 p-4">
<Avatar name={contact.name} src={contact.avatar} size={56} />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-ink-900">{contact.name}</p>
<p className="text-sm text-ink-600">{contact.position || '–'}</p>
<p className="text-xs text-ink-500">{contact.company_name || '–'}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
<Pill tone="emerald">aus dem perinco CRM</Pill>
{contact.kind && <Pill>{contact.kind}</Pill>}
{contact.lead_source && <Pill>{contact.lead_source}</Pill>}
</div>
</div>
</div>
<p className="text-xs text-ink-400">
Dieser Kontakt wird im perinco CRM gepflegt und hier nur angezeigt. Änderungen sind deshalb
nur dort möglich.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<span className={labelClass}>E-Mail</span>
{contact.email ? (
<a href={`mailto:${contact.email}`} className="text-sm text-brand-700 hover:underline">
{contact.email}
</a>
) : (
<span className="text-sm text-ink-400">–</span>
)}
</div>
<div>
<span className={labelClass}>Telefon</span>
{contact.phone ? (
<a
href={`tel:${contact.phone.replace(/[^+\d]/g, '')}`}
className="text-sm text-brand-700 hover:underline"
>
{contact.phone}
</a>
) : (
<span className="text-sm text-ink-400">–</span>
)}
</div>
<div className="sm:col-span-2">
<span className={labelClass}>LinkedIn</span>
{contact.linkedin ? (
<a
href={contact.linkedin}
target="_blank"
rel="noopener noreferrer"
className="break-all text-sm text-brand-700 hover:underline"
>
{contact.linkedin}
</a>
) : (
<span className="text-sm text-ink-400">–</span>
)}
</div>
</div>
{contact.tags.length > 0 && (
<div>
<span className={labelClass}>Schlagworte</span>
<div className="flex flex-wrap gap-1.5">
{contact.tags.map((tag) => (
<Pill key={tag} tone="brand">
{tag}
</Pill>
))}
</div>
</div>
)}
{contact.bio && (
<div>
<span className={labelClass}>Notiz</span>
<p className="whitespace-pre-wrap text-sm text-ink-700">{contact.bio}</p>
</div>
)}
<p className="text-xs text-ink-400">
Angelegt {formatDate(contact.created_at)} · zuletzt geändert {formatDate(contact.updated_at)}
</p>
</div>
</Modal>
);
+33
View File
@@ -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<ContactStatus, string> = {
Interessiert: 'amber',
Interview: 'sky',
Gebucht: 'emerald',
Inaktiv: 'ink',
};
export const KIND_TONE: Record<ContactKind, string> = {
Experte: 'brand',
Ansprechpartner: 'violet',
Lead: 'sky',
};
export const TRISTATE_LABELS: Record<Tristate, string> = {
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`;
}
+537
View File
@@ -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<HTMLInputElement>(null);
const [rows, setRows] = useState<CvRow[]>([]);
const [ai, setAi] = useState<{ configured: boolean; model: string }>({ configured: false, model: '' });
const [folder, setFolder] = useState<Folder>('eingang');
const [busy, setBusy] = useState<number | null>(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 (
<>
<Modal
open
onClose={onClose}
size="xl"
title="CV-Import"
subtitle={
ai.configured
? `Lebensläufe als PDF, DOCX oder Text – ausgelesen von ${ai.model}`
: 'Ohne hinterlegten OpenAI-Schlüssel lässt sich nichts auslesen.'
}
footer={<SecondaryButton onClick={onClose}>Schließen</SecondaryButton>}
>
<div
onDragOver={(event) => {
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'
}`}
>
<p className="text-sm text-ink-600">
Dateien hierher ziehen – mehrere auf einmal sind möglich.
</p>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="mt-2 rounded-lg border border-ink-200 px-3 py-1.5 text-sm font-medium text-ink-700 hover:bg-ink-100 disabled:opacity-50"
>
{uploading ? 'Wird hochgeladen …' : 'Dateien wählen'}
</button>
<p className="mt-2 text-xs text-ink-400">PDF, DOCX oder Text · bis 15 MB je Datei</p>
<input
ref={inputRef}
type="file"
multiple
accept=".pdf,.docx,.txt,.md"
className="hidden"
onChange={(event) => {
void upload(event.target.files);
event.target.value = '';
}}
/>
</div>
<nav className="mb-4 flex gap-1 border-b border-ink-200">
{FOLDERS.map((item) => {
const count = rows.filter((row) => row.status === item.id).length;
return (
<button
key={item.id}
type="button"
onClick={() => setFolder(item.id)}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
folder === item.id
? 'border-brand-500 text-brand-700'
: 'border-transparent text-ink-500 hover:text-ink-800'
}`}
>
{item.label}
{count > 0 && <span className="ml-1.5 text-xs text-ink-400">{count}</span>}
</button>
);
})}
</nav>
{visible.length === 0 ? (
<p className="py-10 text-center text-sm text-ink-400">Dieser Ordner ist leer.</p>
) : (
<ul className="divide-y divide-ink-100 rounded-lg border border-ink-200">
{visible.map((row) => (
<li key={row.id} className="flex flex-wrap items-center gap-3 px-3 py-2.5">
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ink-900">
{row.original_name}
</span>
<span className="block truncate text-xs text-ink-400">
{formatStamp(row.created_at)} · {Math.round(row.size_bytes / 1024)} kB
{row.uploaded_by_name ? ` · ${row.uploaded_by_name}` : ''}
</span>
{row.error && <span className="block text-xs text-red-600">{row.error}</span>}
</span>
{row.extracted?.confidence != null && (
<Pill tone={row.extracted.confidence >= 0.8 ? 'emerald' : 'amber'}>
{Math.round(row.extracted.confidence * 100)} %
</Pill>
)}
<a
href={`/api/cvs/${row.id}/file?token=${encodeURIComponent(getToken() ?? '')}`}
className="rounded-lg px-2 py-1 text-sm font-medium text-ink-600 hover:bg-ink-100"
onClick={(event) => {
// Der Download braucht den Bearer-Kopf, den ein normaler
// Link nicht mitschickt - deshalb ueber fetch und Blob.
event.preventDefault();
void download(row);
}}
>
Datei
</a>
{row.status === 'eingang' && (
<>
<PrimaryButton
type="button"
onClick={() => void analyze(row)}
disabled={busy === row.id || !ai.configured}
className="!px-3 !py-1.5"
>
{busy === row.id ? 'Liest …' : 'Ja, auslesen'}
</PrimaryButton>
<button
type="button"
onClick={() => void reject(row)}
className="rounded-lg px-2 py-1 text-sm font-medium text-ink-500 hover:bg-ink-100"
>
Nein
</button>
</>
)}
{row.status === 'screening' && row.extracted && (
<PrimaryButton
type="button"
onClick={() =>
void openScreening(row)
}
className="!px-3 !py-1.5"
>
Prüfen & übernehmen
</PrimaryButton>
)}
{row.status === 'auswahl' && row.contact_id && (
<button
type="button"
onClick={() => {
open('contact', row.contact_id!);
onClose();
}}
className="rounded-lg px-2 py-1 text-sm font-medium text-brand-700 hover:bg-brand-50"
>
{row.contact_name || 'Kontakt öffnen'}
</button>
)}
{row.status !== 'auswahl' && (
<button
type="button"
onClick={async () => {
await cvsApi.remove(row.id);
await load();
}}
className="rounded p-1 text-ink-400 hover:bg-ink-100 hover:text-red-600"
aria-label="Löschen"
>
×
</button>
)}
</li>
))}
</ul>
)}
</Modal>
{editing && (
<Modal
open
size="lg"
onClose={() => setEditing(null)}
title="Vorschlag prüfen"
subtitle={`Aus ${editing.row.original_name} – Änderungen hier gewinnen gegen die KI.`}
footer={
<>
<SecondaryButton onClick={() => setEditing(null)}>Abbrechen</SecondaryButton>
<PrimaryButton onClick={accept} disabled={busy !== null || !editing.draft.last_name.trim()}>
{busy !== null ? 'Wird angelegt …' : 'Kontakt anlegen'}
</PrimaryButton>
</>
}
>
<SuggestionForm
draft={editing.draft}
onChange={(draft) => setEditing({ ...editing, draft })}
employments={editing.row.extracted?.employments ?? []}
/>
</Modal>
)}
</>
);
};
/* ----------------------------------------------------------------- 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 (
<div className="space-y-5">
{/*
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 && (
<div className="flex items-center gap-4 rounded-lg border border-ink-200 bg-ink-50 p-3">
<img
src={draft.avatar}
alt="Gefundenes Foto"
draggable={false}
className={`h-20 w-20 shrink-0 rounded-lg object-cover ${
draft.useAvatar ? '' : 'opacity-40 grayscale'
}`}
/>
<div className="min-w-0">
<label className="flex items-center gap-2 text-sm font-medium text-ink-800">
<input
type="checkbox"
checked={draft.useAvatar}
onChange={(event) => onChange({ ...draft, useAvatar: event.target.checked })}
className="h-4 w-4 accent-[var(--color-brand-600)]"
/>
Foto aus dem Lebenslauf übernehmen
</label>
<p className="mt-1 text-xs text-ink-400">
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.
</p>
</div>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Vorname">
<input value={draft.first_name} onChange={(e) => set('first_name', e.target.value)} className={inputClass} />
</Field>
<Field label="Nachname *">
<input value={draft.last_name} onChange={(e) => set('last_name', e.target.value)} className={inputClass} />
</Field>
<Field label="Position">
<input value={draft.position} onChange={(e) => set('position', e.target.value)} className={inputClass} />
</Field>
<Field label="Unternehmen" hint="Wird angelegt, falls es noch nicht existiert.">
<input value={draft.company} onChange={(e) => set('company', e.target.value)} className={inputClass} />
</Field>
<Field label="E-Mail">
<input value={draft.email} onChange={(e) => set('email', e.target.value)} className={inputClass} />
</Field>
<Field label="Telefon">
<input value={draft.phone} onChange={(e) => set('phone', e.target.value)} className={inputClass} />
</Field>
<Field label="LinkedIn" className="sm:col-span-2">
<input value={draft.linkedin} onChange={(e) => set('linkedin', e.target.value)} className={inputClass} />
</Field>
<Field label="Stundensatz vor Ort (€/h)">
<input value={draft.rate_onsite} onChange={(e) => set('rate_onsite', e.target.value)} className={inputClass} />
</Field>
<Field label="Stundensatz remote (€/h)">
<input value={draft.rate_remote} onChange={(e) => set('rate_remote', e.target.value)} className={inputClass} />
</Field>
</div>
<Field label="Schlagworte" hint="Kommagetrennt.">
<input value={draft.tags} onChange={(e) => set('tags', e.target.value)} className={inputClass} />
</Field>
<Field label="Zusammenfassung">
<textarea value={draft.bio} onChange={(e) => set('bio', e.target.value)} rows={5} className={inputClass} />
</Field>
{employments.length > 0 && (
<section>
<h3 className="mb-2 text-sm font-semibold text-ink-900">
Frühere Stationen ({employments.length})
</h3>
<p className="mb-2 text-xs text-ink-400">
Werden als beendete Anstellungen angelegt – damit erscheint der Kontakt beim jeweiligen
Unternehmen im Zweig „Verlassen“.
</p>
<ul className="divide-y divide-ink-100 rounded-lg border border-ink-200 text-sm">
{employments.map((entry, index) => (
<li key={index} className="flex items-center gap-2 px-3 py-2">
<span className="font-medium text-ink-900">{entry.company}</span>
<span className="text-ink-500">{entry.position || '–'}</span>
<span className="ml-auto text-xs text-ink-400">
{entry.from || '?'} – {entry.to || '?'}
</span>
</li>
))}
</ul>
</section>
)}
</div>
);
};
function toDraft(suggestion: CvSuggestion): Draft {
return {
avatar: null,
useAvatar: false,
first_name: suggestion.first_name ?? '',
last_name: suggestion.last_name ?? '',
position: suggestion.position ?? '',
email: suggestion.email ?? '',
phone: suggestion.phone ?? '',
linkedin: suggestion.linkedin ?? '',
company: suggestion.company ?? '',
tags: (suggestion.tags ?? []).join(', '),
bio: suggestion.bio ?? '',
rate_onsite: suggestion.rate_onsite == null ? '' : String(suggestion.rate_onsite),
rate_remote: suggestion.rate_remote == null ? '' : String(suggestion.rate_remote),
};
}
function fromDraft(draft: Draft): Record<string, unknown> {
const { useAvatar, avatar, ...fields } = draft;
return {
...fields,
// Der Haken entscheidet; das Feld selbst bleibt gefuellt, damit ein
// versehentliches Abwaehlen sich zuruecknehmen laesst.
avatar: useAvatar ? avatar : null,
tags: draft.tags
.split(',')
.map((tag) => tag.trim())
.filter(Boolean),
rate_onsite: draft.rate_onsite === '' ? null : Number(draft.rate_onsite),
rate_remote: draft.rate_remote === '' ? null : Number(draft.rate_remote),
};
}
/**
* Das gefundene Foto verkleinern - wie jedes andere Foto im CRM, ueber
* denselben Weg (400 px im Quadrat, hoechstens 512 kB).
*
* Scheitert das Verkleinern, faellt nur das Foto weg. Ein Lebenslauf, dessen
* Bild der Browser nicht versteht, soll trotzdem uebernommen werden koennen.
*/
async function photoFields(photo: string | null | undefined): Promise<Partial<Draft>> {
if (!photo) return { avatar: null, useAvatar: false };
try {
return { avatar: await compressImageSource(photo), useAvatar: true };
} catch {
return { avatar: null, useAvatar: false };
}
}
/** Download mit Bearer-Kopf: ein normaler Link schickt ihn nicht mit. */
async function download(row: CvRow) {
const response = await fetch(`/api/cvs/${row.id}/file`, {
headers: { Authorization: `Bearer ${getToken() ?? ''}` },
});
if (!response.ok) return;
const url = URL.createObjectURL(await response.blob());
const link = document.createElement('a');
link.href = url;
link.download = row.original_name;
link.click();
URL.revokeObjectURL(url);
}
+112
View File
@@ -0,0 +1,112 @@
import React, { useEffect, useState } from 'react';
import { dashboardApi, type DashboardData } from '../../lib/api';
import { HistoryFeed } from '../history/HistoryFeed';
import { useNavigation, type OpenTarget } from '../../lib/navigation';
import { useToast } from '../Toast';
/*
* Dashboard: die Zahlen, die man beim Hereinkommen sehen will, und darunter
* die letzten Ereignisse aus der Historie - dieselbe Quelle wie der Reiter
* Historie, nur gekuerzt.
*/
const TILES: Array<{ key: string; label: string; view?: OpenTarget }> = [
{ key: 'contacts', label: 'Kontakte' },
{ key: 'companies', label: 'Unternehmen' },
{ key: 'customers', label: 'Kunden (aktiv)' },
{ key: 'tenders_active', label: 'Aktive Ausschreibungen' },
{ key: 'projects_active', label: 'Laufende Projekte' },
{ key: 'leads_open', label: 'Offene Leads' },
];
export const Dashboard: React.FC = () => {
const { toast } = useToast();
const { open } = useNavigation();
const [data, setData] = useState<DashboardData | null>(null);
useEffect(() => {
dashboardApi
.load()
.then(setData)
.catch((err) => toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error'));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!data) return <p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>;
const maxIndustry = Math.max(1, ...data.companies_by_industry.map((row) => row.count));
return (
<div className="space-y-6">
<ul className="grid gap-3 sm:grid-cols-3 xl:grid-cols-6">
{TILES.map((tile) => (
<li key={tile.key} className="rounded-xl border border-ink-200 bg-surface p-4 shadow-card">
<p className="text-2xl font-bold text-ink-900">{data.counts[tile.key] ?? 0}</p>
<p className="mt-0.5 text-xs text-ink-400">{tile.label}</p>
</li>
))}
</ul>
<div className="grid gap-4 lg:grid-cols-3">
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="mb-3 text-sm font-semibold text-ink-900">Kontakte nach Status</h2>
<ul className="space-y-2">
{data.contacts_by_status.map((row) => (
<li key={row.status} className="flex items-center justify-between text-sm">
<span className="text-ink-600">{row.status}</span>
<span className="font-semibold text-ink-900">{row.count}</span>
</li>
))}
{data.contacts_by_status.length === 0 && (
<li className="text-sm text-ink-400">Noch keine Kontakte.</li>
)}
</ul>
</section>
<section className="rounded-xl border border-ink-200 bg-surface p-5 lg:col-span-2">
<h2 className="mb-3 text-sm font-semibold text-ink-900">Unternehmen nach Branche</h2>
<ul className="space-y-2">
{data.companies_by_industry.map((row) => (
<li key={row.industry} className="text-sm">
<div className="flex items-center justify-between">
<span className="truncate text-ink-600">{row.industry}</span>
<span className="ml-2 shrink-0 font-semibold text-ink-900">{row.count}</span>
</div>
<div className="mt-1 h-1.5 rounded-full bg-ink-100">
<div
className="h-1.5 rounded-full bg-brand-500"
style={{ width: `${(row.count / maxIndustry) * 100}%` }}
/>
</div>
</li>
))}
{data.companies_by_industry.length === 0 && (
<li className="text-sm text-ink-400">Noch keine Unternehmen.</li>
)}
</ul>
</section>
</div>
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="mb-3 text-sm font-semibold text-ink-900">Zuletzt passiert</h2>
<HistoryFeed
entries={data.recent}
searchable={false}
showEntity
emptyText="Noch nichts passiert."
onOpenEntity={(entry) => {
const openable: Record<string, OpenTarget | undefined> = {
contact: 'contact',
company: 'company',
tender: 'tender',
project: 'project',
lead: 'lead',
};
const target = openable[entry.entity_type];
if (target && entry.entity_id) open(target, entry.entity_id);
}}
/>
</section>
</div>
);
};
+130
View File
@@ -0,0 +1,130 @@
import React, { useEffect, useState } from 'react';
import type { DirectoryUser, HistoryEntry } from '../../types';
import { historyApi } from '../../lib/api';
import { HistoryFeed } from './HistoryFeed';
import { inputClass } from '../ui/form';
import { useNavigation, type OpenTarget } from '../../lib/navigation';
import { useToast } from '../Toast';
/*
* Reiter "Historie": alle Taetigkeiten chronologisch, ueber alle Bereiche.
*
* Geblaettert wird ueber die Kennung des letzten Eintrags statt ueber einen
* Versatz - bei laufend neuen Eintraegen wuerde ein Offset Zeilen doppelt
* liefern oder auslassen.
*/
const TYPES: Array<{ value: string; label: string }> = [
{ value: '', label: 'Alle Bereiche' },
{ value: 'contact', label: 'Kontakte' },
{ value: 'company', label: 'Unternehmen' },
{ value: 'tender', label: 'Ausschreibungen' },
{ value: 'project', label: 'Laufende Projekte' },
{ value: 'lead', label: 'Leads' },
{ value: 'user', label: 'Benutzer' },
];
const PAGE_SIZE = 100;
export const History: React.FC<{ users: DirectoryUser[] }> = ({ users }) => {
const { toast } = useToast();
const { open } = useNavigation();
const [entries, setEntries] = useState<HistoryEntry[]>([]);
const [type, setType] = useState('');
const [userId, setUserId] = useState('');
const [term, setTerm] = useState('');
const [loading, setLoading] = useState(true);
const [done, setDone] = useState(false);
const load = async (beforeId?: number) => {
setLoading(true);
try {
const { entries: rows } = await historyApi.list({
entity_type: type || undefined,
user_id: userId || undefined,
q: term.trim() || undefined,
limit: PAGE_SIZE,
before_id: beforeId,
});
setEntries((current) => (beforeId ? [...current, ...rows] : rows));
setDone(rows.length < PAGE_SIZE);
} catch (err) {
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
} finally {
setLoading(false);
}
};
useEffect(() => {
const timer = setTimeout(() => void load(), 200);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type, userId, term]);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder="Historie durchsuchen …"
className={`${inputClass} max-w-xs`}
/>
<select value={type} onChange={(event) => setType(event.target.value)} className={`${inputClass} w-auto`}>
{TYPES.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<select
value={userId}
onChange={(event) => setUserId(event.target.value)}
className={`${inputClass} w-auto`}
>
<option value="">Alle Benutzer</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
<span className="ml-auto text-sm text-ink-400">{entries.length} Einträge</span>
</div>
<div className="rounded-xl border border-ink-200 bg-surface p-5">
<HistoryFeed
entries={entries}
searchable={false}
showEntity
emptyText={loading ? 'Wird geladen …' : 'Noch keine Einträge.'}
onOpenEntity={(entry) => {
if (!entry.entity_id) return;
const openable: Record<string, OpenTarget | undefined> = {
contact: 'contact',
company: 'company',
tender: 'tender',
project: 'project',
lead: 'lead',
};
const target = openable[entry.entity_type];
if (target) open(target, entry.entity_id);
}}
/>
{!done && entries.length > 0 && (
<div className="mt-4 flex justify-center">
<button
type="button"
disabled={loading}
onClick={() => void load(entries[entries.length - 1]?.id)}
className="rounded-lg border border-ink-200 px-4 py-2 text-sm font-medium text-ink-700 hover:bg-ink-100 disabled:opacity-50"
>
{loading ? 'Wird geladen …' : 'Ältere Einträge laden'}
</button>
</div>
)}
</div>
</div>
);
};
+170
View File
@@ -0,0 +1,170 @@
import React, { useMemo, useState } from 'react';
import type { HistoryEntry } from '../../types';
import { formatStamp, userColor } from '../../types';
import { MentionField, MentionText, mentionPlainText } from '../../lib/mentions';
import { Avatar } from '../ui/Avatar';
import { PrimaryButton } from '../ui/form';
/*
* Historie einer Karte.
*
* Jede Zeile traegt Zeitstempel, Benutzer und Text - genau das Format aus der
* Featureliste: 14.09.'26 09:35 | Hans Peter neu angelegt.
*
* Systemeintraege entstehen beim Speichern von selbst; Notizen schreibt der
* Benutzer hier. Beides steht in derselben Liste, damit sich der Verlauf am
* Stueck lesen laesst.
*/
const ACTION_TONE: Record<string, string> = {
created: 'bg-emerald-500',
updated: 'bg-sky-500',
deleted: 'bg-red-500',
note: 'bg-brand-500',
interview: 'bg-violet-500',
left: 'bg-amber-500',
joined: 'bg-emerald-500',
employer: 'bg-amber-500',
connection: 'bg-sky-500',
project: 'bg-brand-500',
stage: 'bg-sky-500',
};
interface HistoryFeedProps {
entries: HistoryEntry[];
/** Ohne diese Angabe ist die Liste rein lesend (z. B. der globale Reiter). */
onAdd?: (text: string) => Promise<void> | void;
/** Suche innerhalb dieser Historie - laut Featureliste je Karte. */
searchable?: boolean;
emptyText?: string;
showEntity?: boolean;
onOpenEntity?: (entry: HistoryEntry) => void;
}
export const HistoryFeed: React.FC<HistoryFeedProps> = ({
entries,
onAdd,
searchable = true,
emptyText = 'Noch keine Einträge.',
showEntity = false,
onOpenEntity,
}) => {
const [draft, setDraft] = useState('');
const [term, setTerm] = useState('');
const [busy, setBusy] = useState(false);
const visible = useMemo(() => {
const q = term.trim().toLowerCase();
if (!q) return entries;
return entries.filter((entry) =>
`${mentionPlainText(entry.text ?? '')} ${entry.entity_label ?? ''} ${entry.user_name ?? ''}`
.toLowerCase()
.includes(q)
);
}, [entries, term]);
const submit = async () => {
const text = draft.trim();
if (!text || !onAdd) return;
setBusy(true);
try {
await onAdd(text);
setDraft('');
} finally {
setBusy(false);
}
};
return (
<div className="space-y-4">
{onAdd && (
<div>
<MentionField
value={draft}
onChange={setDraft}
rows={3}
placeholder="Was ist passiert? Z. B. bei @Kunde vorgestellt …"
/>
<div className="mt-2 flex justify-end">
<PrimaryButton type="button" onClick={submit} disabled={busy || !draft.trim()}>
{busy ? 'Wird gespeichert …' : 'Eintrag hinzufügen'}
</PrimaryButton>
</div>
</div>
)}
{searchable && entries.length > 5 && (
<input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder="In dieser Historie suchen …"
className="w-full rounded-lg border border-ink-200 bg-surface px-3 py-2 text-sm"
/>
)}
{visible.length === 0 ? (
<p className="py-6 text-center text-sm text-ink-400">
{entries.length === 0 ? emptyText : 'Kein Eintrag passt zur Suche.'}
</p>
) : (
<ol className="relative space-y-0 border-l border-ink-200 pl-5">
{visible.map((entry) => (
<li key={entry.id} className="relative py-2.5">
<span
className={`absolute -left-[1.44rem] top-4 h-2.5 w-2.5 rounded-full ring-4 ring-surface ${
ACTION_TONE[entry.action] ?? 'bg-ink-300'
}`}
aria-hidden="true"
/>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<time className="font-mono text-xs text-ink-400">{formatStamp(entry.created_at)}</time>
{entry.user_name && (
<span className="inline-flex items-center gap-1 text-xs text-ink-500">
<Avatar
name={entry.user_name}
src={entry.user_avatar}
size={16}
color={userColor({ id: entry.user_id ?? 0, color: entry.user_color })}
/>
{entry.user_name}
</span>
)}
{showEntity && entry.entity_label && (
<button
type="button"
onClick={() => onOpenEntity?.(entry)}
className="rounded bg-ink-100 px-1.5 py-0.5 text-xs font-medium text-ink-600 hover:bg-ink-200"
>
{entry.entity_label}
</button>
)}
</div>
<p className="mt-0.5 text-sm text-ink-800">
<MentionText text={entry.text} />
</p>
{entry.changes && Object.keys(entry.changes).length > 0 && (
<details className="mt-1">
<summary className="cursor-pointer text-xs text-ink-400">Was genau</summary>
<ul className="mt-1 space-y-0.5 text-xs text-ink-500">
{Object.entries(entry.changes).map(([field, [from, to]]) => (
<li key={field}>
<span className="font-medium">{field}</span>: {display(from)} → {display(to)}
</li>
))}
</ul>
</details>
)}
</li>
))}
</ol>
)}
</div>
);
};
function display(value: unknown): string {
if (value === null || value === undefined || value === '') return '–';
const text = String(value);
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
}
+553
View File
@@ -0,0 +1,553 @@
import React, { useEffect, useState } from 'react';
import {
CONTACT_STATE_LABELS,
LEAD_STAGES,
LEAD_STAGE_LABELS,
formatDate,
formatStamp,
type Company,
type Contact,
type ContactState,
type DirectoryUser,
type Lead,
type LeadStage,
type LeadTodo,
} from '../../types';
import { historyApi, leadsApi } from '../../lib/api';
import { Modal, ConfirmDialog } from '../ui/Modal';
import { Avatar } from '../ui/Avatar';
import { StarRating } from '../ui/StarRating';
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 { useNavigation } from '../../lib/navigation';
import { useAuth } from '../../lib/auth';
import { useToast } from '../Toast';
interface LeadModalProps {
leadId: number | null;
onClose: () => void;
onSaved: () => void;
companies: Company[];
contacts: Contact[];
users: DirectoryUser[];
}
type Tab = 'stamm' | 'todos' | 'historie';
const EMPTY = {
title: '',
company_id: '' as number | '',
contact_id: '' as number | '',
stage: 'Neuer Lead' as LeadStage,
contact_state: 'unbekannt' as ContactState,
notes: '',
owner_id: '' as number | '',
reminder_days: '' as number | '',
};
/*
* Die Karte eines Leads.
*
* Sie traegt alles, was die Featureliste in der Kachel verlangt: die Daten des
* Unternehmens und des Ansprechpartners, die Historie, die Checkliste der
* naechsten Schritte und die Erinnerung.
*/
export const LeadModal: React.FC<LeadModalProps> = ({
leadId,
onClose,
onSaved,
companies,
contacts,
users,
}) => {
const { toast } = useToast();
const { open: openEntity } = useNavigation();
const { user } = useAuth();
const [tab, setTab] = useState<Tab>('stamm');
const [form, setForm] = useState({ ...EMPTY });
const [lead, setLead] = useState<Lead | null>(null);
const [todos, setTodos] = useState<LeadTodo[]>([]);
const [history, setHistory] = useState<React.ComponentProps<typeof HistoryFeed>['entries']>([]);
const [todoText, setTodoText] = useState('');
const [todoDue, setTodoDue] = useState('');
const [remindIn, setRemindIn] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const isNew = leadId === null;
const reload = async () => {
if (leadId === null) return;
const data = await leadsApi.detail(leadId);
setLead(data.lead);
setTodos(data.todos);
setHistory(data.history);
setForm({
title: data.lead.title,
company_id: (data.lead.company_id ?? '') as number | '',
contact_id: (data.lead.contact_id ?? '') as number | '',
stage: data.lead.stage,
contact_state: data.lead.contact_state,
notes: data.lead.notes ?? '',
owner_id: (data.lead.owner_id ?? '') as number | '',
reminder_days: (data.lead.reminder_days ?? '') as number | '',
});
};
useEffect(() => {
if (isNew) {
/*
* 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 ?? '' });
return;
}
setLoading(true);
reload()
.catch((err) => toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error'))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leadId]);
const set = <K extends keyof typeof EMPTY>(key: K, value: (typeof EMPTY)[K]) =>
setForm((current) => ({ ...current, [key]: value }));
const save = async () => {
if (!form.title.trim()) {
toast('Der Titel ist erforderlich.', 'error');
return;
}
setSaving(true);
try {
const body: Record<string, unknown> = {
...form,
company_id: form.company_id === '' ? null : Number(form.company_id),
contact_id: form.contact_id === '' ? null : Number(form.contact_id),
owner_id: form.owner_id === '' ? null : Number(form.owner_id),
reminder_days: form.reminder_days === '' ? null : Number(form.reminder_days),
};
if (remindIn) body.remind_in_days = Number(remindIn);
if (isNew) await leadsApi.create(body);
else await leadsApi.update(leadId, 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 leadsApi.remove(leadId);
toast('Lead gelöscht.', 'success');
onSaved();
onClose();
} catch (err) {
toast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
} finally {
setConfirmDelete(false);
}
};
const addTodo = async () => {
if (leadId === null || !todoText.trim()) return;
await leadsApi.addTodo(leadId, todoText.trim(), todoDue || null);
setTodoText('');
setTodoDue('');
await reload();
};
const toggleTodo = async (todo: LeadTodo) => {
await leadsApi.updateTodo(todo.id, { done: !todo.done });
await reload();
};
const company = companies.find((item) => item.id === Number(form.company_id));
const contact = contacts.find((item) => item.id === Number(form.contact_id));
const tabs: Array<{ id: Tab; label: string; count?: number }> = [
{ id: 'stamm', label: 'Stammdaten' },
{ id: 'todos', label: 'ToDos', count: todos.filter((todo) => !todo.done).length },
{ id: 'historie', label: 'Historie', count: history.length },
];
return (
<>
<Modal
open
onClose={onClose}
size="lg"
title={isNew ? 'Neuer Lead' : form.title || 'Lead'}
subtitle={[company?.name, contact?.name].filter(Boolean).join(' · ')}
footer={
<>
{!isNew && (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="mr-auto rounded-lg px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
>
Löschen
</button>
)}
<SecondaryButton type="button" onClick={onClose}>
Abbrechen
</SecondaryButton>
<PrimaryButton type="button" onClick={save} disabled={saving}>
{saving ? 'Speichern …' : 'Speichern'}
</PrimaryButton>
</>
}
>
{loading ? (
<p className="py-10 text-center text-sm text-ink-400">Wird geladen …</p>
) : (
<>
{!isNew && (
<nav className="mb-5 flex gap-1 border-b border-ink-200">
{tabs.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setTab(item.id)}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
tab === item.id
? 'border-brand-500 text-brand-700'
: 'border-transparent text-ink-500 hover:text-ink-800'
}`}
>
{item.label}
{item.count !== undefined && item.count > 0 && (
<span className="ml-1.5 text-xs text-ink-400">{item.count}</span>
)}
</button>
))}
</nav>
)}
{(isNew || tab === 'stamm') && (
<div className="space-y-5">
<Field label="Titel *">
<input
value={form.title}
onChange={(event) => set('title', event.target.value)}
placeholder="z. B. Rahmenvertrag Finanz Informatik"
className={inputClass}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Unternehmen">
<select
value={form.company_id}
onChange={(event) =>
set('company_id', event.target.value === '' ? '' : Number(event.target.value))
}
className={inputClass}
>
<option value="">– keines –</option>
{companies.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</Field>
<Field label="Ansprechpartner">
<select
value={form.contact_id}
onChange={(event) =>
set('contact_id', event.target.value === '' ? '' : Number(event.target.value))
}
className={inputClass}
>
<option value="">– keiner –</option>
{contacts.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
{item.company_name ? ` · ${item.company_name}` : ''}
</option>
))}
</select>
</Field>
</div>
{(company || contact) && (
<div className="grid gap-3 sm:grid-cols-2">
{company && (
<button
type="button"
onClick={() => openEntity('company', company.id)}
className="flex items-start gap-3 rounded-lg border border-ink-200 p-3 text-left hover:bg-brand-50"
>
<Avatar name={company.name} src={company.logo} size={36} square />
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-ink-900">
{company.name}
</span>
<span className="block truncate text-xs text-ink-400">
{[company.industry, company.employees ? `${company.employees} MA` : null]
.filter(Boolean)
.join(' · ') || 'Keine Stammdaten'}
</span>
<StarRating value={company.rating} size={12} className="mt-1" />
</span>
</button>
)}
{contact && (
<button
type="button"
onClick={() => openEntity('contact', contact.id)}
className="flex items-start gap-3 rounded-lg border border-ink-200 p-3 text-left hover:bg-brand-50"
>
<Avatar name={contact.name} src={contact.avatar} size={36} />
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-ink-900">
{contact.name}
</span>
<span className="block truncate text-xs text-ink-400">
{contact.position || contact.kind}
</span>
<span className="block truncate text-xs text-ink-400">
{contact.email || contact.phone || '–'}
</span>
</span>
</button>
)}
</div>
)}
<div className="grid gap-4 sm:grid-cols-3">
<Field label="Phase">
<select
value={form.stage}
onChange={(event) => set('stage', event.target.value as LeadStage)}
className={inputClass}
>
{LEAD_STAGES.map((stage) => (
<option key={stage} value={stage}>
{LEAD_STAGE_LABELS[stage]}
</option>
))}
</select>
</Field>
<Field label="Kontaktstand">
<select
value={form.contact_state}
onChange={(event) => set('contact_state', event.target.value as ContactState)}
className={inputClass}
>
{(Object.keys(CONTACT_STATE_LABELS) as ContactState[]).map((state) => (
<option key={state} value={state}>
{CONTACT_STATE_LABELS[state]}
</option>
))}
</select>
</Field>
<Field label="Zuständig">
<select
value={form.owner_id}
onChange={(event) =>
set('owner_id', event.target.value === '' ? '' : Number(event.target.value))
}
className={inputClass}
>
<option value="">– niemand –</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</Field>
</div>
<section className="rounded-lg border border-ink-200 p-4">
<h3 className="mb-3 text-sm font-semibold text-ink-900">Erinnerung</h3>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="In wie vielen Tagen erinnern?" hint="Beim Speichern gesetzt.">
<input
type="number"
min={1}
value={remindIn}
onChange={(event) => setRemindIn(event.target.value)}
placeholder="z. B. 2"
className={inputClass}
/>
</Field>
<Field
label="Standardintervall (Tage)"
hint="Vorschlag beim nächsten Mal – ändert nichts von allein."
>
<input
type="number"
min={1}
value={form.reminder_days}
onChange={(event) =>
set('reminder_days', event.target.value === '' ? '' : Number(event.target.value))
}
className={inputClass}
/>
</Field>
</div>
{lead?.remind_at && (
<p className="mt-2 text-xs text-ink-500">
Nächste Erinnerung: <strong>{formatStamp(lead.remind_at)}</strong>
</p>
)}
{lead?.last_contact_at && (
<p className="mt-1 text-xs text-ink-400">
Zuletzt angefasst: {formatStamp(lead.last_contact_at)} – bleibt ein Lead einen
Monat unberührt, fragt das CRM von selbst nach.
</p>
)}
</section>
<div>
<div className="mb-1 flex items-center justify-between gap-2">
<label className={`${labelClass} mb-0`}>Notizen</label>
<DictateButton
label="Notizen"
compact
onBeforeNavigate={onClose}
onText={(text) =>
set('notes', form.notes.trim() ? `${form.notes.trimEnd()} ${text}` : text)
}
/>
</div>
<MentionField value={form.notes} onChange={(value) => set('notes', value)} rows={4} />
</div>
</div>
)}
{!isNew && tab === 'todos' && (
<div className="space-y-4">
{/*
Fortschritt auf einen Blick: erledigt von gesamt als Balken.
Bei leerer Liste bleibt er weg - 0 % ohne Aufgaben wäre keine
Aussage, sondern ein Vorwurf.
*/}
{todos.length > 0 && (
<div>
<div className="mb-1 flex items-center justify-between text-xs text-ink-500">
<span>
{todos.filter((todo) => todo.done).length} von {todos.length} erledigt
</span>
<span className="font-semibold text-ink-700">
{Math.round((todos.filter((todo) => todo.done).length / todos.length) * 100)} %
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-ink-100">
<div
className="h-2 rounded-full bg-brand-500 transition-all"
style={{
width: `${(todos.filter((todo) => todo.done).length / todos.length) * 100}%`,
}}
/>
</div>
</div>
)}
<ul className="divide-y divide-ink-100 rounded-lg border border-ink-200">
{todos.length === 0 && (
<li className="px-3 py-6 text-center text-sm text-ink-400">
Noch keine ToDos.
</li>
)}
{todos.map((todo) => (
<li key={todo.id} className="flex items-center gap-3 px-3 py-2.5">
<input
type="checkbox"
checked={todo.done}
onChange={() => void toggleTodo(todo)}
className="h-4 w-4 accent-[var(--color-brand-600)]"
/>
<span
className={`min-w-0 flex-1 text-sm ${
todo.done ? 'text-ink-400 line-through' : 'text-ink-800'
}`}
>
{todo.text}
</span>
{todo.due_on && (
<Pill tone={isOverdue(todo) ? 'red' : 'ink'}>{formatDate(todo.due_on)}</Pill>
)}
<button
type="button"
onClick={async () => {
await leadsApi.removeTodo(todo.id);
await reload();
}}
className="rounded p-1 text-ink-400 hover:bg-ink-100 hover:text-red-600"
aria-label="Aufgabe entfernen"
>
×
</button>
</li>
))}
</ul>
<div className="flex flex-wrap items-end gap-2">
<Field label="Neues ToDo" className="min-w-[16rem] flex-1">
<input
value={todoText}
onChange={(event) => setTodoText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void addTodo();
}}
placeholder="z. B. Angebot nachfassen"
className={inputClass}
/>
</Field>
<Field label="Fällig">
<input
type="date"
value={todoDue}
onChange={(event) => setTodoDue(event.target.value)}
className={inputClass}
/>
</Field>
<PrimaryButton type="button" onClick={addTodo} disabled={!todoText.trim()}>
Hinzufügen
</PrimaryButton>
</div>
</div>
)}
{!isNew && tab === 'historie' && (
<HistoryFeed
entries={history}
onAdd={async (text) => {
await historyApi.add('lead', leadId, text);
await reload();
}}
/>
)}
</>
)}
</Modal>
<ConfirmDialog
open={confirmDelete}
title="Lead löschen"
message={`"${form.title}" wird endgültig gelöscht. Zum Aufheben stattdessen archivieren.`}
onConfirm={remove}
onCancel={() => setConfirmDelete(false)}
/>
</>
);
};
function isOverdue(todo: LeadTodo): boolean {
if (todo.done || !todo.due_on) return false;
return todo.due_on < new Date().toISOString().slice(0, 10);
}
+369
View File
@@ -0,0 +1,369 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
LEAD_STAGES,
LEAD_STAGE_LABELS,
CONTACT_STATE_LABELS,
formatDate,
formatStamp,
type Company,
type Contact,
type DirectoryUser,
type Lead,
type LeadStage,
} from '../../types';
import { contactsApi, leadsApi } from '../../lib/api';
import { Avatar } from '../ui/Avatar';
import { StarRating } from '../ui/StarRating';
import { Pill, PrimaryButton, inputClass } from '../ui/form';
import { ConfirmDialog } from '../ui/Modal';
import { LeadModal } from './LeadModal';
import { useToast } from '../Toast';
import { useKanbanDrag, cardClass, columnClass } from '../../lib/kanban';
interface LeadsProps {
companies: Company[];
users: DirectoryUser[];
onChanged: () => void;
openId: number | null;
onOpenHandled: () => void;
/** true = der Unterpunkt "Archiviert"; sonst die laufende Pipeline. */
archivedView?: boolean;
}
/** Phasenfarbe - dieselbe Zuordnung in Kachel und Archivliste. */
const STAGE_TONE: Record<LeadStage, string> = {
'Fuer Spaeter': 'ink',
'Neuer Lead': 'sky',
Erstkontakt: 'sky',
'1. Gespraech': 'violet',
'Followup geplant': 'amber',
'Followup Gespraech': 'amber',
Closed: 'emerald',
Absage: 'red',
};
/*
* Lead-Pipeline als Kanban.
*
* Die aktiven Leads stehen im Board, alles Abgeschlossene im Reiter Archiv -
* dort als Tabelle, aus der sich ein Lead wieder zurueckholen laesst.
*/
export const Leads: React.FC<LeadsProps> = ({
companies,
users,
onChanged,
openId,
onOpenHandled,
archivedView = false,
}) => {
const { toast } = useToast();
const [leads, setLeads] = useState<Lead[]>([]);
const [archived, setArchived] = useState<Lead[]>([]);
const [contacts, setContacts] = useState<Contact[]>([]);
const [loading, setLoading] = useState(true);
const [term, setTerm] = useState('');
const [editing, setEditing] = useState<number | null | undefined>(undefined);
const [deleting, setDeleting] = useState<Lead | null>(null);
const load = async () => {
setLoading(true);
try {
const [active, done, { contacts: people }] = await Promise.all([
leadsApi.list(false),
leadsApi.list(true),
contactsApi.list(),
]);
setLeads(active.leads);
setArchived(done.leads);
setContacts(people);
} 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]);
const matches = (lead: Lead) => {
const q = term.trim().toLowerCase();
if (!q) return true;
return [lead.title, lead.company_name, lead.contact_name, lead.notes, lead.owner_name]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(q);
};
const visible = useMemo(() => leads.filter(matches), [leads, term]);
const visibleArchived = useMemo(() => archived.filter(matches), [archived, term]);
const moveTo = async (id: number, stage: LeadStage) => {
const lead = leads.find((item) => item.id === id);
if (!lead || lead.stage === stage) return;
// Sofort umsetzen, damit die Kachel nicht am Zeiger klebt; bei einem Fehler
// wird der Stand neu geladen.
setLeads((current) => current.map((item) => (item.id === id ? { ...item, stage } : item)));
try {
await leadsApi.update(id, { stage });
onChanged();
} catch (err) {
toast(err instanceof Error ? err.message : 'Verschieben fehlgeschlagen.', 'error');
void load();
}
};
const drag = useKanbanDrag<LeadStage>((id, stage) => void moveTo(id, stage));
const setArchivedFlag = async (lead: Lead, value: boolean) => {
try {
await leadsApi.update(lead.id, { archived: value });
toast(value ? 'Lead archiviert.' : 'Lead zurückgeholt.', 'success');
await load();
onChanged();
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder="Leads filtern …"
className={`${inputClass} max-w-xs`}
/>
<span className="ml-auto text-sm text-ink-400">
{archivedView ? `${visibleArchived.length} archiviert` : `${visible.length} offen`}
</span>
{!archivedView && (
<PrimaryButton type="button" onClick={() => setEditing(null)}>
Neuer Lead
</PrimaryButton>
)}
</div>
{loading ? (
<p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>
) : !archivedView ? (
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-slim">
{LEAD_STAGES.map((stage) => {
const items = visible.filter((lead) => lead.stage === stage);
return (
<section
key={stage}
{...drag.columnProps(stage)}
className={`flex w-72 shrink-0 flex-col rounded-xl border transition-colors ${columnClass(
drag.hoverColumn === stage
)}`}
>
<header className="flex items-center justify-between border-b border-ink-200 px-3 py-2">
<h3 className="text-sm font-semibold text-ink-800">{LEAD_STAGE_LABELS[stage]}</h3>
<span className="rounded-full bg-ink-100 px-2 py-0.5 text-xs font-medium text-ink-500">
{items.length}
</span>
</header>
<ul className="flex-1 space-y-2 p-2">
{items.map((lead) => (
<li key={lead.id}>
<div
{...drag.cardProps(lead.id)}
onClick={() => setEditing(lead.id)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter') setEditing(lead.id);
}}
className={`w-full cursor-grab rounded-lg border border-ink-200 bg-surface p-3 text-left shadow-card active:cursor-grabbing ${cardClass(
drag.draggedId === lead.id
)}`}
>
<p className="line-clamp-2 text-sm font-medium text-ink-900">{lead.title}</p>
{lead.company_name && (
<span className="mt-2 flex items-center gap-2">
<Avatar name={lead.company_name} src={lead.company_logo} size={20} square />
<span className="min-w-0 flex-1 truncate text-xs text-ink-600">
{lead.company_name}
</span>
{lead.company_rating ? (
<StarRating value={lead.company_rating} size={10} />
) : null}
</span>
)}
{lead.contact_name && (
<span className="mt-1 flex items-center gap-2">
<Avatar name={lead.contact_name} src={lead.contact_avatar} size={20} />
<span className="truncate text-xs text-ink-500">{lead.contact_name}</span>
</span>
)}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{lead.contact_state !== 'unbekannt' && (
<Pill tone={lead.contact_state === 'persoenlich' ? 'emerald' : 'ink'}>
{CONTACT_STATE_LABELS[lead.contact_state]}
</Pill>
)}
{lead.todo_count > 0 && (
<Pill tone={lead.todo_done === lead.todo_count ? 'emerald' : 'sky'}>
{lead.todo_done}/{lead.todo_count} ToDo
</Pill>
)}
{lead.remind_at && (
<Pill tone={isDue(lead.remind_at) ? 'red' : 'amber'}>
{isDue(lead.remind_at) ? 'fällig' : formatDate(lead.remind_at)}
</Pill>
)}
</div>
<div className="mt-2 flex items-center justify-between text-xs text-ink-400">
<span className="truncate">{lead.owner_name ?? 'niemand zuständig'}</span>
<span className="flex shrink-0 items-center gap-0.5">
{/* Aktenschrank = archivieren, Mülleimer = löschen. */}
<button
type="button"
title="Archivieren"
aria-label="Archivieren"
onClick={(event) => {
event.stopPropagation();
void setArchivedFlag(lead, true);
}}
className="rounded p-1 text-ink-400 hover:bg-ink-100 hover:text-brand-700"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4h16v6H4V4Zm0 10h16v6H4v-6Zm5-3h6" />
</svg>
</button>
<button
type="button"
title="Löschen"
aria-label="Löschen"
onClick={(event) => {
event.stopPropagation();
setDeleting(lead);
}}
className="rounded p-1 text-red-500 hover:bg-red-50 hover:text-red-600"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.9 12.1A2 2 0 0 1 16.1 21H7.9a2 2 0 0 1-2-1.9L5 7m5 4v6m4-6v6m1-10V4a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v3M4 7h16" />
</svg>
</button>
</span>
</div>
</div>
</li>
))}
{items.length === 0 && (
<li className="rounded-lg border border-dashed border-ink-200 px-3 py-6 text-center text-xs text-ink-400">
Hierher ziehen
</li>
)}
</ul>
</section>
);
})}
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-ink-200 bg-surface">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-ink-200 text-left text-xs uppercase tracking-wide text-ink-500">
<th className="px-4 py-2.5 font-semibold">Lead</th>
<th className="px-4 py-2.5 font-semibold">Unternehmen</th>
<th className="px-4 py-2.5 font-semibold">Ansprechpartner</th>
<th className="px-4 py-2.5 font-semibold">Phase</th>
<th className="px-4 py-2.5 font-semibold">Zuständig</th>
<th className="px-4 py-2.5 font-semibold">Zuletzt</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{visibleArchived.map((lead) => (
<tr key={lead.id} className="hover:bg-ink-50">
<td
className="cursor-pointer px-4 py-2.5 font-medium text-ink-900"
onClick={() => setEditing(lead.id)}
>
{lead.title}
</td>
<td className="px-4 py-2.5 text-ink-600">{lead.company_name || '–'}</td>
<td className="px-4 py-2.5 text-ink-600">{lead.contact_name || '–'}</td>
<td className="px-4 py-2.5">
<Pill tone={STAGE_TONE[lead.stage]}>{LEAD_STAGE_LABELS[lead.stage]}</Pill>
</td>
<td className="px-4 py-2.5 text-ink-600">{lead.owner_name || '–'}</td>
<td className="px-4 py-2.5 text-xs text-ink-400">
{formatStamp(lead.last_contact_at ?? lead.updated_at)}
</td>
<td className="px-4 py-2.5 text-right">
<button
type="button"
onClick={() => void setArchivedFlag(lead, false)}
className="rounded-lg px-2 py-1 text-sm font-medium text-brand-700 hover:bg-brand-50"
>
zurückschieben
</button>
</td>
</tr>
))}
</tbody>
</table>
{visibleArchived.length === 0 && (
<p className="py-12 text-center text-sm text-ink-400">Nichts im Archiv.</p>
)}
</div>
)}
<ConfirmDialog
open={deleting !== null}
title="Lead löschen"
message={`„${deleting?.title}" wird endgültig gelöscht. Zum Aufheben stattdessen archivieren.`}
onCancel={() => setDeleting(null)}
onConfirm={async () => {
if (!deleting) return;
try {
await leadsApi.remove(deleting.id);
toast('Lead gelöscht.', 'success');
await load();
onChanged();
} catch (err) {
toast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
} finally {
setDeleting(null);
}
}}
/>
{editing !== undefined && (
<LeadModal
leadId={editing}
companies={companies}
contacts={contacts}
users={users}
onClose={() => setEditing(undefined)}
onSaved={() => {
void load();
onChanged();
}}
/>
)}
</div>
);
};
function isDue(value: string): boolean {
return new Date(value.replace(' ', 'T')).getTime() <= Date.now();
}
+160
View File
@@ -0,0 +1,160 @@
import React, { useCallback, useEffect, useState } from 'react';
import { formatStamp, type Lead } from '../../types';
import { leadsApi } from '../../lib/api';
import { Modal } from '../ui/Modal';
import { Avatar } from '../ui/Avatar';
import { PrimaryButton, SecondaryButton } from '../ui/form';
import { useNavigation } from '../../lib/navigation';
import { useToast } from '../Toast';
/*
* Erinnerungen an Leads.
*
* Zwei Faelle, beide als Pop-up:
*
* faellig eine gesetzte Erinnerung ist erreicht ("in 2 Tagen anrufen")
* alt ein Lead wurde einen Monat lang nicht angefasst; dann fragt das
* CRM, ob er archiviert werden soll. Wird er das nicht, laeuft die
* Frist von vorn - das erledigt der Server, indem das Quittieren
* `last_contact_at` auf jetzt setzt.
*
* Abgefragt wird beim Anmelden und danach stuendlich. Oefter waere sinnlos:
* die Fristen sind Tage, nicht Minuten.
*/
const POLL_MS = 60 * 60 * 1000;
export const ReminderWatcher: React.FC<{ onChanged: () => void }> = ({ onChanged }) => {
const { toast } = useToast();
const { open } = useNavigation();
const [queue, setQueue] = useState<Array<{ lead: Lead; kind: 'due' | 'stale' }>>([]);
const [staleDays, setStaleDays] = useState(30);
const check = useCallback(async () => {
try {
const result = await leadsApi.reminders();
setStaleDays(result.stale_days);
setQueue([
...result.due.map((lead) => ({ lead, kind: 'due' as const })),
...result.stale.map((lead) => ({ lead, kind: 'stale' as const })),
]);
} catch {
/* Kein Netz oder abgelaufene Sitzung - beim naechsten Durchlauf erneut. */
}
}, []);
useEffect(() => {
void check();
const timer = setInterval(() => void check(), POLL_MS);
return () => clearInterval(timer);
}, [check]);
const current = queue[0];
if (!current) return null;
const { lead, kind } = current;
const next = () => setQueue((list) => list.slice(1));
const acknowledge = async (snoozeDays?: number) => {
try {
await leadsApi.acknowledge(lead.id, snoozeDays);
if (snoozeDays) toast(`Erinnerung um ${snoozeDays} Tag(e) verschoben.`, 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
next();
onChanged();
}
};
const archive = async () => {
try {
await leadsApi.update(lead.id, { archived: true });
toast(`"${lead.title}" archiviert.`, 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Archivieren fehlgeschlagen.', 'error');
} finally {
next();
onChanged();
}
};
// Vorschlag fuer das Verschieben: das am Lead gepflegte Intervall, sonst zwei Tage.
const snooze = lead.reminder_days && lead.reminder_days > 0 ? lead.reminder_days : 2;
return (
<Modal
open
size="sm"
onClose={() => void acknowledge()}
title={kind === 'due' ? 'Erinnerung' : 'Lead liegt lange still'}
subtitle={queue.length > 1 ? `${queue.length} offene Erinnerungen` : undefined}
footer={
kind === 'due' ? (
<>
<SecondaryButton type="button" onClick={() => void acknowledge(snooze)}>
In {snooze} Tag(en) erneut
</SecondaryButton>
<PrimaryButton type="button" onClick={() => void acknowledge()}>
Erledigt
</PrimaryButton>
</>
) : (
<>
<SecondaryButton type="button" onClick={() => void acknowledge()}>
Nicht archivieren
</SecondaryButton>
<PrimaryButton type="button" onClick={archive}>
Archivieren
</PrimaryButton>
</>
)
}
>
<p className="text-sm text-ink-800">
{kind === 'due' ? (
<>
<strong>{lead.title}</strong>
{lead.company_name ? ` – ${lead.company_name}` : ''}
{lead.contact_name ? ` · ${lead.contact_name} anrufen` : ''}
</>
) : (
<>
Achtung: <strong>{lead.title}</strong> wurde seit {staleDays} Tagen nicht kontaktiert. Soll
der Lead archiviert werden?
</>
)}
</p>
<div className="mt-4 space-y-2 rounded-lg border border-ink-200 p-3">
{lead.company_name && (
<span className="flex items-center gap-2 text-sm text-ink-700">
<Avatar name={lead.company_name} src={lead.company_logo} size={24} square />
{lead.company_name}
</span>
)}
{lead.contact_name && (
<span className="flex items-center gap-2 text-sm text-ink-700">
<Avatar name={lead.contact_name} src={lead.contact_avatar} size={24} />
{lead.contact_name}
{lead.contact_phone ? ` · ${lead.contact_phone}` : ''}
</span>
)}
<p className="text-xs text-ink-400">
Zuletzt angefasst: {formatStamp(lead.last_contact_at ?? lead.created_at)}
</p>
</div>
<button
type="button"
onClick={() => {
next();
open('lead', lead.id);
}}
className="mt-3 text-sm font-medium text-brand-700 hover:underline"
>
Lead öffnen
</button>
</Modal>
);
};
+292
View File
@@ -0,0 +1,292 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { initials } from '../../types';
/*
* Stammbaum eines Unternehmens.
*
* Wird in der Netzwerkansicht geöffnet, sobald man auf ein Unternehmen klickt,
* an dem Kontakte hängen. Statt des Kräftespiels steht hier eine feste
* Hierarchie: das Unternehmen oben, die Kontakte darunter, verbunden durch
* Äste.
*
* Die Farbe eines Astes sagt, welcher Art die Verbindung ist:
*
* golden Berater (Kontaktart „Experte")
* hell normale Verbindung - Ansprechpartner und übrige Kontakte
* grau ehemalige, also eine beendete Anstellung
*
* Der graue Ast ist zusätzlich gestrichelt. Im hellen Modus wäre ein „weißer"
* Ast auf weißem Grund unsichtbar; die normale Verbindung nimmt deshalb
* ink-700, das im Dunkelmodus nahezu weiß und im hellen Modus dunkelgrau ist.
* Damit sich Grau und Neutral dort trotzdem nicht ähneln, trägt nur der
* ehemalige Ast die Strichelung.
*/
export type Branch = 'berater' | 'normal' | 'ehemalig';
export interface TreeChild {
id: number;
name: string;
position: string | null;
avatar: string | null;
branch: Branch;
/** Nur bei Ehemaligen gesetzt: wo die Person heute ist. */
nowCompany?: string | null;
}
export interface TreeRoot {
id: number;
name: string;
logo?: string | null;
industry?: string | null;
}
const BRANCH_STYLE: Record<Branch, { stroke: string; width: number; dash?: string; label: string }> = {
berater: { stroke: 'var(--color-brand-500)', width: 2.2, label: 'Berater' },
normal: { stroke: 'var(--color-ink-700)', width: 1.6, label: 'Normale Verbindung' },
ehemalig: { stroke: 'var(--color-ink-300)', width: 1.4, dash: '5 5', label: 'Ehemalige' },
};
/** Berater zuerst, dann die übrigen, ganz hinten die Ehemaligen. */
const ORDER: Record<Branch, number> = { berater: 0, normal: 1, ehemalig: 2 };
const CHILD_WIDTH = 150;
const ROW_HEIGHT = 132;
const ROOT_Y = 58;
const FIRST_ROW_Y = 190;
interface CompanyTreeProps {
root: TreeRoot;
children: TreeChild[];
onOpenCompany: () => void;
onOpenContact: (id: number) => void;
onBack: () => void;
}
export const CompanyTree: React.FC<CompanyTreeProps> = ({
root,
children,
onOpenCompany,
onOpenContact,
onBack,
}) => {
const boxRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ width: 900, height: 520 });
const [hovered, setHovered] = useState<number | null>(null);
useEffect(() => {
const element = boxRef.current;
if (!element) return;
const observer = new ResizeObserver(([entry]) => {
setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
});
observer.observe(element);
return () => observer.disconnect();
}, []);
/*
* Die Kinder werden zeilenweise verteilt. Passen nicht alle in eine Zeile,
* bricht die Reihe um - ein Unternehmen mit dreißig Kontakten soll den
* Stammbaum nicht in die Breite ziehen, bis nichts mehr lesbar ist.
*/
const laid = useMemo(() => {
const sorted = [...children].sort(
(a, b) => ORDER[a.branch] - ORDER[b.branch] || a.name.localeCompare(b.name, 'de')
);
const usable = Math.max(CHILD_WIDTH, size.width - 40);
const perRow = Math.max(1, Math.floor(usable / CHILD_WIDTH));
return sorted.map((child, index) => {
const row = Math.floor(index / perRow);
const column = index % perRow;
const inRow = Math.min(perRow, sorted.length - row * perRow);
// Jede Zeile für sich mittig ausrichten, sonst hängt die letzte links.
const spread = inRow * CHILD_WIDTH;
const x = size.width / 2 - spread / 2 + column * CHILD_WIDTH + CHILD_WIDTH / 2;
return { ...child, x, y: FIRST_ROW_Y + row * ROW_HEIGHT };
});
}, [children, size.width]);
const rows = laid.length === 0 ? 1 : Math.max(...laid.map((child) => child.y)) + ROW_HEIGHT;
const height = Math.max(size.height, rows);
const rootX = size.width / 2;
const counts = useMemo(() => {
const result: Record<Branch, number> = { berater: 0, normal: 0, ehemalig: 0 };
for (const child of children) result[child.branch] += 1;
return result;
}, [children]);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={onBack}
className="rounded-lg border border-ink-200 px-3 py-1.5 text-sm font-medium text-ink-700 hover:bg-ink-100"
>
← Zurück zum Netz
</button>
<span className="text-sm font-semibold text-ink-900">{root.name}</span>
<span className="text-xs text-ink-400">{children.length} Kontakt(e)</span>
<span className="ml-auto flex flex-wrap items-center gap-3 text-xs text-ink-400">
{(Object.keys(BRANCH_STYLE) as Branch[]).map((branch) => (
<span key={branch} className="inline-flex items-center gap-1.5">
<svg width="22" height="8" aria-hidden="true">
<line
x1="0"
y1="4"
x2="22"
y2="4"
stroke={BRANCH_STYLE[branch].stroke}
strokeWidth={BRANCH_STYLE[branch].width}
strokeDasharray={BRANCH_STYLE[branch].dash}
/>
</svg>
{BRANCH_STYLE[branch].label} ({counts[branch]})
</span>
))}
</span>
</div>
<div
ref={boxRef}
className="relative h-[calc(100vh-18rem)] overflow-auto rounded-xl border border-ink-200 bg-surface scrollbar-slim"
>
<svg width="100%" height={height} className="block">
{/* Die Äste zuerst - sie sollen unter den Knoten liegen. */}
{laid.map((child) => {
const style = BRANCH_STYLE[child.branch];
const dimmed = hovered !== null && hovered !== child.id;
// Weicher Bogen statt Gerade: bei vielen Kindern nebeneinander
// bleiben die Äste so auseinanderzuhalten.
const midY = (ROOT_Y + 34 + child.y) / 2;
return (
<path
key={child.id}
d={`M ${rootX} ${ROOT_Y + 34} C ${rootX} ${midY}, ${child.x} ${midY}, ${child.x} ${child.y - 30}`}
fill="none"
stroke={style.stroke}
strokeWidth={style.width}
strokeDasharray={style.dash}
opacity={dimmed ? 0.18 : 0.85}
/>
);
})}
{/* Wurzel: das Unternehmen. */}
<g
transform={`translate(${rootX} ${ROOT_Y})`}
className="cursor-pointer"
onClick={onOpenCompany}
>
<rect
x={-70}
y={-26}
width={140}
height={52}
rx={4}
fill="var(--color-brand-500)"
stroke="var(--color-ink-900)"
strokeWidth={1.5}
/>
<text
textAnchor="middle"
dominantBaseline="central"
fontSize={12}
fontWeight={600}
fill="#ffffff"
style={{ pointerEvents: 'none' }}
>
{root.name.length > 20 ? `${root.name.slice(0, 20)}…` : root.name}
</text>
{root.industry && (
<text
y={40}
textAnchor="middle"
fontSize={10}
fill="var(--color-ink-400)"
style={{ pointerEvents: 'none' }}
>
{root.industry}
</text>
)}
</g>
{/* Die Kontakte. */}
{laid.map((child) => {
const style = BRANCH_STYLE[child.branch];
const dimmed = hovered !== null && hovered !== child.id;
return (
<g
key={child.id}
transform={`translate(${child.x} ${child.y})`}
opacity={dimmed ? 0.3 : 1}
className="cursor-pointer"
onMouseEnter={() => setHovered(child.id)}
onMouseLeave={() => setHovered(null)}
onClick={() => onOpenContact(child.id)}
>
{child.avatar ? (
<>
<clipPath id={`clip-${child.id}`}>
<circle r={18} />
</clipPath>
<image
href={child.avatar}
x={-18}
y={-18}
width={36}
height={36}
clipPath={`url(#clip-${child.id})`}
preserveAspectRatio="xMidYMid slice"
/>
</>
) : (
<circle r={18} fill="var(--color-ink-400)" />
)}
<circle r={18} fill="none" stroke={style.stroke} strokeWidth={2} />
{!child.avatar && (
<text
textAnchor="middle"
dominantBaseline="central"
fontSize={11}
fill="#ffffff"
style={{ pointerEvents: 'none' }}
>
{initials(child.name)}
</text>
)}
<text
y={34}
textAnchor="middle"
fontSize={11}
fontWeight={500}
fill="var(--color-ink-800)"
style={{ pointerEvents: 'none' }}
>
{child.name.length > 20 ? `${child.name.slice(0, 20)}…` : child.name}
</text>
<text
y={48}
textAnchor="middle"
fontSize={10}
fill="var(--color-ink-400)"
style={{ pointerEvents: 'none' }}
>
{(child.branch === 'ehemalig' && child.nowCompany
? `heute: ${child.nowCompany}`
: child.position || BRANCH_STYLE[child.branch].label
).slice(0, 24)}
</text>
</g>
);
})}
</svg>
</div>
</div>
);
};
+741
View File
@@ -0,0 +1,741 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
forceCenter,
forceCollide,
forceLink,
forceManyBody,
forceSimulation,
type Simulation,
type SimulationLinkDatum,
type SimulationNodeDatum,
} from 'd3-force';
import { connectionsApi, networkApi, type NetworkData } from '../../lib/api';
import { useNavigation } from '../../lib/navigation';
import { StarRating } from '../ui/StarRating';
import { Pill, inputClass } from '../ui/form';
import { useToast } from '../Toast';
import { initials, type PerincoContact } from '../../types';
import { CompanyTree, type Branch, type TreeChild } from './CompanyTree';
/*
* Netzwerkansicht.
*
* Sie ist keine eigene Seite, sondern die dritte Ansicht der Kontakte und der
* Unternehmen - wie im perinco CRM. Dieselben Daten, dieselben Filter, nur
* anders gezeichnet.
*
* Kraftgerichtetes Layout (d3-force), gerendert als SVG:
*
* Rechteck Unternehmen - Groesse nach Anzahl Verbindungen, Rahmen bei Kunden
* Kreis Kontakt
* Goldene Linie Kontakt arbeitet dort
* Graue gestrichelte Linie Kontakt hat dort gearbeitet ("Verlassen")
* Feine Linie Kontakt kennt Kontakt
*
* Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt auf den Zeiger,
* Knoten ziehen fixiert ihn, Doppelklick loest die Fixierung, Klick oeffnet die
* Karte. Ueber "Verbindung erstellen" werden zwei Kontakte angeklickt, um eine
* Kante anzulegen; Esc bricht ab.
*
* Das SVG kann keine Tailwind-Klassen stapeln und benutzt deshalb
* var(--color-*) direkt - damit folgt es trotzdem dem Modus.
*/
interface Node extends SimulationNodeDatum {
key: string;
/** external = Kontakt aus dem perinco CRM, nur angezeigt. */
type: 'company' | 'contact' | 'external';
id: number;
label: string;
hint: string | null;
rating: number;
isCustomer: boolean;
degree: number;
}
interface Link extends SimulationLinkDatum<Node> {
kind: 'employment' | 'former' | 'contact' | 'external';
id?: number;
}
interface NetworkProps {
/**
* Aus welcher Liste die Ansicht aufgerufen wurde. Der Graph ist derselbe -
* der Modus entscheidet nur, was hervorgehoben wird und ob sich Verbindungen
* anlegen lassen.
*/
mode: 'companies' | 'contacts';
/** Suchbegriff der aufrufenden Liste, damit beide Ansichten gleich filtern. */
term?: string;
/** Nur Kunden zeigen - kommt aus dem Umschalter der Unternehmensliste. */
onlyCustomers?: boolean;
/** Mindestbewertung aus dem Filter der Unternehmensliste. */
minRating?: number;
industry?: string;
/**
* Kontakte aus dem perinco CRM. Sie erscheinen mit, wenn der Schalter in der
* Kontaktliste an ist - gruen umrandet und ohne Bearbeitung.
*/
externals?: PerincoContact[];
}
/*
* Eine einzige leere Liste fuer alle Aufrufer ohne Fremdkontakte.
*
* Ein Vorgabewert `externals = []` waere bei jedem Render ein neues Array -
* und damit eine neue Abhaengigkeit fuer useMemo und useEffect. Die Folge war
* eine Simulation, die sich endlos selbst neu startete: alle Knoten blieben
* auf ihrer Startposition uebereinander liegen.
*/
const NO_EXTERNALS: PerincoContact[] = [];
export const Network: React.FC<NetworkProps> = ({
mode,
term = '',
onlyCustomers = false,
minRating = 0,
industry = '',
externals = NO_EXTERNALS,
}) => {
const { toast } = useToast();
const { open } = useNavigation();
const svgRef = useRef<SVGSVGElement>(null);
const simulationRef = useRef<Simulation<Node, Link> | null>(null);
const [data, setData] = useState<NetworkData | null>(null);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Node | null>(null);
/** Gesetzt, solange der Stammbaum eines Unternehmens offen ist. */
const [treeCompanyId, setTreeCompanyId] = useState<number | null>(null);
const [connecting, setConnecting] = useState<{ first: Node | null } | null>(null);
const [localTerm, setLocalTerm] = useState('');
const [camera, setCamera] = useState({ x: 0, y: 0, k: 1 });
const [, forceRedraw] = useState(0);
/*
* Die Groesse der Zeichenflaeche wird gemessen, nicht geraten: der Ursprung
* des Kraftlayouts liegt bei (0,0), und die Gruppe muss ihn in die Mitte
* ruecken.
*/
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const element = svgRef.current;
if (!element) return;
const observer = new ResizeObserver(([entry]) => {
setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
});
observer.observe(element);
return () => observer.disconnect();
}, [loading]);
const load = async () => {
setLoading(true);
try {
setData(
await networkApi.load({
rating_min: minRating || undefined,
scope: onlyCustomers ? 'customers' : undefined,
industry: industry || undefined,
})
);
} 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
}, [minRating, onlyCustomers, industry]);
// Esc bricht das Anlegen einer Verbindung ab.
useEffect(() => {
if (!connecting) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setConnecting(null);
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [connecting]);
const { nodes, links } = useMemo(() => {
if (!data) return { nodes: [] as Node[], links: [] as Link[] };
const byKey = new Map<string, Node>();
const push = (node: Omit<Node, 'degree'>) => {
if (!byKey.has(node.key)) byKey.set(node.key, { ...node, degree: 0 });
return byKey.get(node.key)!;
};
for (const company of data.companies) {
push({
key: `company-${company.id}`,
type: 'company',
id: company.id,
label: company.name,
hint: company.industry ?? null,
rating: company.rating ?? 0,
isCustomer: Boolean(company.is_customer),
});
}
for (const contact of data.contacts) {
push({
key: `contact-${contact.id}`,
type: 'contact',
id: contact.id,
label: contact.name,
hint: contact.position ?? null,
rating: 0,
isCustomer: false,
});
}
const edges: Link[] = [];
for (const edge of data.edges) {
if (edge.type === 'group') continue;
const source =
edge.type === 'contact' ? byKey.get(`contact-${edge.a}`) : byKey.get(`contact-${edge.contact_id}`);
const target =
edge.type === 'contact' ? byKey.get(`contact-${edge.b}`) : byKey.get(`company-${edge.company_id}`);
if (!source || !target) continue;
source.degree += 1;
target.degree += 1;
edges.push({
source,
target,
kind: edge.type as Link['kind'],
id: edge.id as number | undefined,
});
}
/*
* Kontakte aus dem perinco CRM.
*
* Sie tragen drueben nur den Firmennamen als Text, keine Kennung. Verbunden
* werden sie deshalb ueber den Namen - schreibt jemand dort "KfW" und hier
* "KFW", trifft es trotzdem. Findet sich kein Unternehmen, steht der
* Kontakt fuer sich; das ist ehrlicher als eine geratene Zuordnung.
*/
const companyByName = new Map(
data.companies.map((company) => [company.name.trim().toLowerCase(), company.id])
);
for (const contact of externals) {
const node = push({
key: `ext-${contact.id}`,
type: 'external',
id: 0,
label: contact.name,
hint: contact.position ?? contact.company_name ?? 'perinco',
rating: 0,
isCustomer: false,
});
const companyId = contact.company_name
? companyByName.get(contact.company_name.trim().toLowerCase())
: undefined;
const target = companyId ? byKey.get(`company-${companyId}`) : undefined;
if (!target) continue;
node.degree += 1;
target.degree += 1;
edges.push({ source: node, target, kind: 'external' });
}
return { nodes: [...byKey.values()], links: edges };
}, [data, externals]);
// Die Simulation laeuft nur, solange sie sich bewegt; jeder Tick loest ein
// Neuzeichnen aus. Die Knotenobjekte veraendert d3 selbst - sie liegen
// deshalb bewusst ausserhalb des React-Zustands.
useEffect(() => {
simulationRef.current?.stop();
if (nodes.length === 0) return;
const simulation = forceSimulation<Node>(nodes)
.force(
'link',
forceLink<Node, Link>(links)
.id((node) => node.key)
.distance((link) => (link.kind === 'contact' ? 70 : 110))
.strength(0.35)
)
.force('charge', forceManyBody().strength(-260))
.force('center', forceCenter(0, 0))
.force('collide', forceCollide<Node>((node) => radius(node) + 14))
.alpha(1);
simulation.on('tick', () => forceRedraw((value) => value + 1));
simulationRef.current = simulation;
return () => {
simulation.stop();
};
}, [nodes, links]);
const highlighted = useMemo(() => {
if (!selected) return null;
const keys = new Set<string>([selected.key]);
for (const link of links) {
const source = link.source as Node;
const target = link.target as Node;
if (source.key === selected.key) keys.add(target.key);
if (target.key === selected.key) keys.add(source.key);
}
return keys;
}, [selected, links]);
const search = (term || localTerm).trim().toLowerCase();
const matches = (node: Node) => !search || node.label.toLowerCase().includes(search);
/**
* Die Kontakte eines Unternehmens für den Stammbaum.
*
* Die Art des Astes ergibt sich aus der Beziehung, nicht aus der Person: wer
* das Unternehmen verlassen hat, hängt am grauen Ast - auch wenn er Berater
* ist. Erst unter den heutigen wird zwischen Berater (golden) und übrigen
* Kontakten (hell) unterschieden.
*/
const childrenOf = (companyId: number): TreeChild[] => {
if (!data) return [];
const byId = new Map(data.contacts.map((contact) => [contact.id, contact]));
const companyName = new Map(data.companies.map((company) => [company.id, company.name]));
const result = new Map<number, TreeChild>();
for (const edge of data.edges) {
if (edge.type !== 'employment' && edge.type !== 'former') continue;
if (Number(edge.company_id) !== companyId) continue;
const contact = byId.get(Number(edge.contact_id));
if (!contact) continue;
const branch: Branch =
edge.type === 'former' ? 'ehemalig' : contact.kind === 'Experte' ? 'berater' : 'normal';
// Ein aktueller Eintrag gewinnt gegen einen früheren derselben Person:
// wer wieder hier arbeitet, gehört nicht unter "Ehemalige".
const existing = result.get(contact.id);
if (existing && existing.branch !== 'ehemalig') continue;
result.set(contact.id, {
id: contact.id,
name: contact.name,
position: contact.position,
avatar: contact.avatar,
branch,
nowCompany:
branch === 'ehemalig' && contact.company_id ? (companyName.get(contact.company_id) ?? null) : null,
});
}
return [...result.values()];
};
const tree = useMemo(() => {
if (treeCompanyId === null || !data) return null;
const company = data.companies.find((item) => item.id === treeCompanyId);
if (!company) return null;
return {
root: { id: company.id, name: company.name, logo: company.logo, industry: company.industry },
children: childrenOf(company.id),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [treeCompanyId, data]);
/* ---------------------------------------------------------------- Gesten */
const dragRef = useRef<{ node: Node | null; startX: number; startY: number; moved: boolean } | null>(
null
);
const toWorld = (event: React.PointerEvent) => {
const rect = svgRef.current!.getBoundingClientRect();
return {
x: (event.clientX - rect.left - rect.width / 2 - camera.x) / camera.k,
y: (event.clientY - rect.top - rect.height / 2 - camera.y) / camera.k,
};
};
const onPointerDown = (event: React.PointerEvent, node: Node | null) => {
(event.target as Element).setPointerCapture?.(event.pointerId);
dragRef.current = { node, startX: event.clientX, startY: event.clientY, moved: false };
if (node) {
simulationRef.current?.alphaTarget(0.25).restart();
const point = toWorld(event);
node.fx = point.x;
node.fy = point.y;
}
};
const onPointerMove = (event: React.PointerEvent) => {
const drag = dragRef.current;
if (!drag) return;
// Erst ab vier Pixeln gilt die Geste als Ziehen - sonst verwirft jeder
// leicht wackelnde Klick die Auswahl.
if (!drag.moved) {
if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) < 4) return;
drag.moved = true;
}
if (drag.node) {
const point = toWorld(event);
drag.node.fx = point.x;
drag.node.fy = point.y;
} else {
setCamera((current) => ({
...current,
x: current.x + event.movementX,
y: current.y + event.movementY,
}));
}
};
const connect = async (node: Node) => {
if (node.type !== 'contact') {
toast(
node.type === 'external'
? 'Kontakte aus dem perinco CRM lassen sich hier nicht verbinden.'
: 'Verbindungen gibt es nur zwischen Kontakten.',
'error'
);
return;
}
if (!connecting?.first) {
setConnecting({ first: node });
return;
}
if (connecting.first.id === node.id) {
setConnecting({ first: null });
return;
}
try {
await connectionsApi.create(connecting.first.id, node.id);
toast(`${connecting.first.label} ↔ ${node.label} verbunden.`, 'success');
setConnecting(null);
await load();
} catch (err) {
toast(err instanceof Error ? err.message : 'Verbinden fehlgeschlagen.', 'error');
}
};
const onPointerUp = (event: React.PointerEvent, node: Node | null) => {
const drag = dragRef.current;
dragRef.current = null;
simulationRef.current?.alphaTarget(0);
event.stopPropagation();
if (!drag || drag.moved) return;
if (connecting && node) {
void connect(node);
return;
}
/*
* Ein Unternehmen mit Kontakten öffnet den Stammbaum - das ist die
* eigentliche Frage an dieser Stelle ("wer sitzt da?"). Ein Unternehmen
* ohne Kontakte bleibt bei der Auswahl, sonst führte der Klick auf eine
* leere Ansicht.
*/
if (node?.type === 'company' && childrenOf(node.id).length > 0) {
setSelected(null);
setTreeCompanyId(node.id);
return;
}
// Klick auf freie Flaeche hebt die Auswahl auf - der Ausgangszustand.
setSelected(node ?? null);
};
const onWheel = (event: React.WheelEvent) => {
event.preventDefault();
const rect = svgRef.current!.getBoundingClientRect();
const px = event.clientX - rect.left - rect.width / 2;
const py = event.clientY - rect.top - rect.height / 2;
setCamera((current) => {
const factor = event.deltaY < 0 ? 1.12 : 1 / 1.12;
const k = Math.min(3, Math.max(0.25, current.k * factor));
// Auf den Zeiger zoomen: der Punkt unter der Maus bleibt stehen.
return {
k,
x: px - ((px - current.x) / current.k) * k,
y: py - ((py - current.y) / current.k) * k,
};
});
};
const reset = () => {
setCamera({ x: 0, y: 0, k: 1 });
for (const node of nodes) {
node.fx = null;
node.fy = null;
}
simulationRef.current?.alpha(0.8).restart();
};
/*
* Der Stammbaum ersetzt die Netzansicht, statt sie zu überlagern: beide
* zeigen dieselben Beziehungen, und zwei Darstellungen übereinander würden
* nur um die Aufmerksamkeit konkurrieren.
*/
if (tree) {
return (
<CompanyTree
root={tree.root}
children={tree.children}
onBack={() => setTreeCompanyId(null)}
onOpenCompany={() => open('company', tree.root.id)}
onOpenContact={(id) => open('contact', id)}
/>
);
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
{/* Die Unternehmensliste bringt ihre Filter schon mit; in den Kontakten
gibt es hier ein eigenes Suchfeld. */}
{!term && (
<input
value={localTerm}
onChange={(event) => setLocalTerm(event.target.value)}
placeholder="Im Netz suchen …"
className={`${inputClass} max-w-xs`}
/>
)}
{mode === 'contacts' && (
<button
type="button"
onClick={() => setConnecting(connecting ? null : { first: null })}
className={`rounded-lg border px-3 py-1.5 text-sm font-medium ${
connecting
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-ink-200 text-ink-700 hover:bg-ink-100'
}`}
>
{connecting ? 'Verbinden abbrechen (Esc)' : 'Verbindung erstellen'}
</button>
)}
<button
type="button"
onClick={reset}
className="rounded-lg border border-ink-200 px-3 py-1.5 text-sm font-medium text-ink-700 hover:bg-ink-100"
>
Ansicht zurücksetzen
</button>
<span className="ml-auto flex flex-wrap items-center gap-3 text-xs text-ink-400">
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2.5 w-3 rounded-sm bg-[var(--color-brand-500)]" /> Unternehmen
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-full bg-[var(--color-ink-400)]" /> Kontakt
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block h-px w-4 bg-[var(--color-brand-500)]" /> tätig
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block h-px w-4 border-t border-dashed border-[var(--color-ink-400)]" />
verlassen
</span>
{externals.length > 0 && (
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-full border-2 border-emerald-500" />
perinco ({externals.length})
</span>
)}
</span>
</div>
{connecting && (
<p className="rounded-lg border border-brand-200 bg-brand-50 px-3 py-2 text-sm text-brand-700">
{connecting.first
? `${connecting.first.label} gewählt – jetzt den zweiten Kontakt anklicken.`
: 'Zwei Kontakte nacheinander anklicken, um sie zu verbinden. Esc bricht ab.'}
</p>
)}
<div className="relative h-[calc(100vh-18rem)] overflow-hidden rounded-xl border border-ink-200 bg-surface">
{loading ? (
<p className="p-16 text-center text-sm text-ink-400">Wird geladen …</p>
) : nodes.length === 0 ? (
<p className="p-16 text-center text-sm text-ink-400">Noch nichts zu zeigen.</p>
) : (
<svg
ref={svgRef}
className={`h-full w-full touch-none ${
connecting ? 'cursor-crosshair' : 'cursor-grab active:cursor-grabbing'
}`}
onPointerDown={(event) => onPointerDown(event, null)}
onPointerMove={onPointerMove}
onPointerUp={(event) => onPointerUp(event, null)}
onWheel={onWheel}
>
<g
transform={`translate(${size.width / 2 + camera.x} ${size.height / 2 + camera.y}) scale(${camera.k})`}
>
{/* Die Kanten zuerst: sie sollen unter den Knoten liegen. */}
{links.map((link, index) => {
const source = link.source as Node;
const target = link.target as Node;
const dimmed =
highlighted && !(highlighted.has(source.key) && highlighted.has(target.key));
return (
<line
key={index}
x1={source.x ?? 0}
y1={source.y ?? 0}
x2={target.x ?? 0}
y2={target.y ?? 0}
stroke={
link.kind === 'employment'
? 'var(--color-brand-500)'
: link.kind === 'former'
? 'var(--color-ink-400)'
: link.kind === 'external'
? '#10b981'
: 'var(--color-ink-300)'
}
strokeWidth={link.kind === 'employment' ? 1.6 : 1}
strokeDasharray={
link.kind === 'former' ? '4 4' : link.kind === 'external' ? '2 3' : undefined
}
opacity={dimmed ? 0.12 : 0.7}
/>
);
})}
{nodes.map((node) => {
const dimmed = (highlighted && !highlighted.has(node.key)) || !matches(node);
const r = radius(node);
const isFirst = connecting?.first?.key === node.key;
return (
<g
key={node.key}
transform={`translate(${node.x ?? 0} ${node.y ?? 0})`}
opacity={dimmed ? 0.22 : 1}
className="cursor-pointer"
onPointerDown={(event) => {
event.stopPropagation();
onPointerDown(event, node);
}}
onPointerUp={(event) => onPointerUp(event, node)}
onDoubleClick={(event) => {
event.stopPropagation();
node.fx = null;
node.fy = null;
simulationRef.current?.alpha(0.4).restart();
}}
>
{node.type === 'external' ? (
<circle r={r} fill="var(--color-surface)" stroke="#10b981" strokeWidth={2.5} />
) : node.type === 'company' ? (
<rect
x={-r}
y={-r * 0.72}
width={r * 2}
height={r * 1.44}
rx={4}
fill="var(--color-brand-500)"
stroke={node.isCustomer ? 'var(--color-ink-900)' : 'none'}
strokeWidth={node.isCustomer ? 2 : 0}
/>
) : (
<circle
r={r}
fill="var(--color-ink-400)"
stroke={isFirst ? 'var(--color-brand-500)' : 'none'}
strokeWidth={isFirst ? 3 : 0}
/>
)}
<text
y={node.type === 'company' ? r * 1.44 : r + 12}
textAnchor="middle"
fontSize={11}
fill="var(--color-ink-700)"
style={{ pointerEvents: 'none' }}
>
{node.label.length > 24 ? `${node.label.slice(0, 24)}…` : node.label}
</text>
{(node.type === 'contact' || node.type === 'external') && r >= 12 && (
<text
textAnchor="middle"
dominantBaseline="central"
fontSize={9}
fill={node.type === 'external' ? 'var(--color-ink-700)' : '#ffffff'}
style={{ pointerEvents: 'none' }}
>
{initials(node.label)}
</text>
)}
{node.fx !== null && node.fx !== undefined && (
<circle
cx={r * 0.8}
cy={-r * 0.8}
r={3}
fill="var(--color-brand-600)"
style={{ pointerEvents: 'none' }}
/>
)}
</g>
);
})}
</g>
</svg>
)}
{selected && (
<aside className="absolute right-4 top-4 w-64 rounded-xl border border-ink-200 bg-surface p-4 shadow-lift">
<p className="text-sm font-semibold text-ink-900">{selected.label}</p>
<p className="mt-0.5 text-xs text-ink-400">
{selected.type === 'company'
? 'Unternehmen'
: selected.type === 'external'
? 'Kontakt (perinco)'
: 'Kontakt'}
{selected.hint ? ` · ${selected.hint}` : ''} · {selected.degree} Verbindung(en)
</p>
{selected.type === 'company' && (
<div className="mt-2 flex items-center gap-2">
<StarRating value={selected.rating} size={14} />
{selected.isCustomer && <Pill tone="emerald">Kunde</Pill>}
</div>
)}
{selected.type === 'external' ? (
<p className="mt-3 rounded-lg border-2 border-emerald-500 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
Kontakt aus dem perinco CRM – wird dort gepflegt.
</p>
) : (
<button
type="button"
onClick={() => open(selected.type as 'company' | 'contact', selected.id)}
className="mt-3 w-full rounded-lg bg-brand-600 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-700"
>
Karte öffnen
</button>
)}
</aside>
)}
</div>
</div>
);
};
/** Knotengroesse nach Anzahl Verbindungen - gedeckelt, sonst frisst ein Hub die Flaeche. */
function radius(node: Node): number {
const base = node.type === 'company' ? 16 : 9;
return Math.min(base + node.degree * 1.6, node.type === 'company' ? 42 : 22);
}
+375
View File
@@ -0,0 +1,375 @@
import React, { useEffect, useMemo, useState } from 'react';
import { formatDate, type Company, type Contact, type Project } from '../../types';
import { contactsApi, projectsApi } from '../../lib/api';
import { Modal, ConfirmDialog } from '../ui/Modal';
import { Avatar } from '../ui/Avatar';
import { Field, Pill, PrimaryButton, SecondaryButton, inputClass, labelClass } from '../ui/form';
import { RichTextEditor } from '../ui/RichTextEditor';
import { DictateButton, appendParagraph } from '../ui/Dictate';
import { toEditorHtml } from '../../lib/richText';
import { useToast } from '../Toast';
interface ProjectsProps {
companies: Company[];
onChanged: () => void;
openId: number | null;
onOpenHandled: () => void;
}
const STATUS = ['Aktiv', 'Nicht aktiv', 'Abgeschlossen'] as const;
const STATUS_TONE: Record<string, string> = {
Aktiv: 'emerald',
'Nicht aktiv': 'amber',
Abgeschlossen: 'ink',
};
export const Projects: React.FC<ProjectsProps> = ({ companies, onChanged, openId, onOpenHandled }) => {
const { toast } = useToast();
const [projects, setProjects] = useState<Project[]>([]);
const [contacts, setContacts] = useState<Contact[]>([]);
const [loading, setLoading] = useState(true);
const [term, setTerm] = useState('');
const [status, setStatus] = useState('');
const [editing, setEditing] = useState<Project | null | undefined>(undefined);
const load = async () => {
setLoading(true);
try {
const [{ projects: rows }, { contacts: people }] = await Promise.all([
projectsApi.list(),
contactsApi.list(),
]);
setProjects(rows);
setContacts(people);
} 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;
const project = projects.find((item) => item.id === openId);
if (project) setEditing(project);
onOpenHandled();
}, [openId, projects, onOpenHandled]);
const visible = useMemo(() => {
const q = term.trim().toLowerCase();
return projects.filter((project) => {
if (status && project.status !== status) return false;
if (!q) return true;
return [project.title, project.company_name, project.contact_name]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(q);
});
}, [projects, term, status]);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder="Projekte filtern …"
className={`${inputClass} max-w-xs`}
/>
<select
value={status}
onChange={(event) => setStatus(event.target.value)}
className={`${inputClass} w-auto`}
>
<option value="">Alle Status</option>
{STATUS.map((value) => (
<option key={value}>{value}</option>
))}
</select>
<span className="ml-auto text-sm text-ink-400">
{visible.length} von {projects.length}
</span>
<PrimaryButton type="button" onClick={() => setEditing(null)}>
Neues Projekt
</PrimaryButton>
</div>
{loading ? (
<p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>
) : visible.length === 0 ? (
<p className="py-16 text-center text-sm text-ink-400">
{projects.length === 0 ? 'Noch kein Projekt erfasst.' : 'Kein Projekt passt zum Filter.'}
</p>
) : (
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{visible.map((project) => (
<li key={project.id}>
<button
type="button"
onClick={() => setEditing(project)}
className="flex h-full w-full flex-col gap-2 rounded-xl border border-ink-200 bg-surface p-4 text-left shadow-card hover:shadow-lift"
>
<div className="flex items-start justify-between gap-2">
<p className="font-semibold text-ink-900">{project.title}</p>
<Pill tone={STATUS_TONE[project.status]}>{project.status}</Pill>
</div>
<p className="text-sm text-ink-500">{project.company_name || 'Kein Unternehmen'}</p>
{project.contact_name && (
<span className="flex items-center gap-2 text-sm text-ink-600">
<Avatar name={project.contact_name} src={project.contact_avatar} size={22} />
{project.contact_name}
</span>
)}
<p className="mt-auto pt-1 text-xs text-ink-400">
{formatDate(project.started_on)} – {project.ended_on ? formatDate(project.ended_on) : 'offen'}
{project.rate ? ` · ${project.rate.toLocaleString('de-DE')} €` : ''}
</p>
</button>
</li>
))}
</ul>
)}
{editing !== undefined && (
<ProjectModal
project={editing}
companies={companies}
contacts={contacts}
onClose={() => setEditing(undefined)}
onSaved={() => {
void load();
onChanged();
}}
/>
)}
</div>
);
};
const EMPTY = {
title: '',
company_id: '' as number | '',
contact_id: '' as number | '',
started_on: '',
ended_on: '',
status: 'Aktiv',
rate: '',
notes: '',
};
const ProjectModal: React.FC<{
project: Project | null;
companies: Company[];
contacts: Contact[];
onClose: () => void;
onSaved: () => void;
}> = ({ project, companies, contacts, onClose, onSaved }) => {
const { toast } = useToast();
const [form, setForm] = useState({
...EMPTY,
...(project
? {
title: project.title,
company_id: (project.company_id ?? '') as number | '',
contact_id: (project.contact_id ?? '') as number | '',
started_on: project.started_on ?? '',
ended_on: project.ended_on ?? '',
status: project.status,
rate: project.rate === null ? '' : String(project.rate),
notes: toEditorHtml(project.notes),
}
: {}),
});
const [saving, setSaving] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const set = <K extends keyof typeof EMPTY>(key: K, value: (typeof EMPTY)[K]) =>
setForm((current) => ({ ...current, [key]: value }));
const save = async () => {
if (!form.title.trim()) {
toast('Der Titel ist erforderlich.', 'error');
return;
}
setSaving(true);
try {
const body = {
...form,
company_id: form.company_id === '' ? null : Number(form.company_id),
contact_id: form.contact_id === '' ? null : Number(form.contact_id),
started_on: form.started_on || null,
ended_on: form.ended_on || null,
rate: form.rate === '' ? null : Number(form.rate),
};
if (project) await projectsApi.update(project.id, body);
else await projectsApi.create(body);
toast('Gespeichert.', 'success');
onSaved();
onClose();
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setSaving(false);
}
};
const remove = async () => {
if (!project) return;
try {
await projectsApi.remove(project.id);
toast('Projekt gelöscht.', 'success');
onSaved();
onClose();
} catch (err) {
toast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
} finally {
setConfirmDelete(false);
}
};
return (
<>
<Modal
open
onClose={onClose}
title={project ? form.title || 'Projekt' : 'Neues Projekt'}
footer={
<>
{project && (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="mr-auto rounded-lg px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
>
Löschen
</button>
)}
<SecondaryButton type="button" onClick={onClose}>
Abbrechen
</SecondaryButton>
<PrimaryButton type="button" onClick={save} disabled={saving}>
{saving ? 'Speichern …' : 'Speichern'}
</PrimaryButton>
</>
}
>
<div className="space-y-5">
<Field label="Titel *">
<input
value={form.title}
onChange={(event) => set('title', event.target.value)}
className={inputClass}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Unternehmen">
<select
value={form.company_id}
onChange={(event) =>
set('company_id', event.target.value === '' ? '' : Number(event.target.value))
}
className={inputClass}
>
<option value="">– keines –</option>
{companies.map((company) => (
<option key={company.id} value={company.id}>
{company.name}
</option>
))}
</select>
</Field>
<Field label="Kontakt">
<select
value={form.contact_id}
onChange={(event) =>
set('contact_id', event.target.value === '' ? '' : Number(event.target.value))
}
className={inputClass}
>
<option value="">– keiner –</option>
{contacts.map((contact) => (
<option key={contact.id} value={contact.id}>
{contact.name}
</option>
))}
</select>
</Field>
<Field label="Beginn">
<input
type="date"
value={form.started_on}
onChange={(event) => set('started_on', event.target.value)}
className={inputClass}
/>
</Field>
<Field label="Ende">
<input
type="date"
value={form.ended_on}
onChange={(event) => set('ended_on', event.target.value)}
className={inputClass}
/>
</Field>
<Field label="Status">
<select
value={form.status}
onChange={(event) => set('status', event.target.value)}
className={inputClass}
>
{STATUS.map((value) => (
<option key={value}>{value}</option>
))}
</select>
</Field>
<Field label="Tagessatz">
<input
type="number"
value={form.rate}
onChange={(event) => set('rate', event.target.value)}
className={inputClass}
/>
</Field>
</div>
<div>
<label className={labelClass}>Notizen</label>
{/*
Die Notizen enthalten im Bestand HTML - der uebernommene
Ausschreibungstext steht dort unter ein paar Kopfzeilen. In einem
einfachen Textfeld waeren die spitzen Klammern sichtbar.
*/}
<RichTextEditor
value={form.notes}
onChange={(value) => set('notes', value)}
minHeight={220}
placeholder="Notizen zum Projekt …"
actions={
<DictateButton
label="Notizen"
compact
onBeforeNavigate={onClose}
onText={(text) => set('notes', appendParagraph(form.notes, text))}
/>
}
/>
</div>
</div>
</Modal>
<ConfirmDialog
open={confirmDelete}
title="Projekt löschen"
message={`"${form.title}" wird endgültig gelöscht.`}
onConfirm={remove}
onCancel={() => setConfirmDelete(false)}
/>
</>
);
};
+231
View File
@@ -0,0 +1,231 @@
import React, { useEffect, useState } from 'react';
import { aiApi, type AiConfig } from '../../lib/api';
import { Field, PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
import { ConfirmDialog } from '../ui/Modal';
import { useToast } from '../Toast';
/*
* Reiter "KI" in den Einstellungen (nur Administratoren).
*
* Der Schlüssel lag bisher ausschließlich in server/.env. Das hieß: ein
* abgelaufener Schlüssel ist ein Serverbesuch. Er steht jetzt in der Datenbank
* und lässt sich hier wechseln - die .env bleibt als Startwert und als Rückfall.
*
* Zwei Dinge sind hier nicht Bequemlichkeit, sondern Bedingung:
*
* * Der Schlüssel kommt **nie** zurück zum Browser. Angezeigt wird nur eine
* verkürzte Form; das Eingabefeld ist leer und schreibt nur.
* * Ein Schlüssel gilt erst als richtig, wenn ein Aufruf damit geklappt hat.
* Deshalb der Test - sonst merkt man den Tippfehler erst, wenn mitten in
* der Arbeit ein Lebenslauf nicht ausgelesen wird.
*/
export const AiSettings: React.FC = () => {
const { toast } = useToast();
const [config, setConfig] = useState<AiConfig | null>(null);
const [key, setKey] = useState('');
const [model, setModel] = useState('');
const [busy, setBusy] = useState(false);
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
const [askRemove, setAskRemove] = useState(false);
const loadConfig = async () => {
try {
const data = await aiApi.config();
setConfig(data);
setModel(data.model);
} catch (err) {
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
}
};
useEffect(() => {
void loadConfig();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const save = async (changes: { key?: string; model?: string }) => {
setBusy(true);
setResult(null);
try {
const data = await aiApi.saveConfig(changes);
setConfig(data);
setModel(data.model);
setKey('');
toast('Gespeichert.', 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setBusy(false);
}
};
const runTest = async () => {
setTesting(true);
setResult(null);
try {
const data = await aiApi.test();
setResult({ ok: true, text: `Antwort in ${(data.ms / 1000).toFixed(1)} s von ${data.model}.` });
} catch (err) {
setResult({ ok: false, text: err instanceof Error ? err.message : 'Der Test ist fehlgeschlagen.' });
} finally {
setTesting(false);
}
};
if (!config) return <p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>;
const sourceLabel =
config.source === 'db'
? 'hier hinterlegt'
: config.source === 'env'
? 'aus server/.env'
: 'keiner hinterlegt';
return (
<div className="space-y-5">
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="text-base font-semibold text-ink-900">OpenAI</h2>
<p className="mt-2 max-w-3xl text-sm text-ink-500">
Der Schlüssel treibt drei Dinge an: das Auslesen der Lebensläufe, die Schreibhilfe in den
Ausschreibungen und die erweiterte Suche. Er bleibt auf dem Server – der Browser spricht
nie direkt mit OpenAI. Fehlt er, läuft das CRM normal weiter und blendet die KI-Knöpfe
aus.
</p>
<div className="mt-4 flex flex-wrap items-center gap-3 rounded-lg border border-ink-200 bg-ink-50 px-4 py-3">
<span
className={`inline-flex h-2.5 w-2.5 shrink-0 rounded-full ${
config.configured ? 'bg-emerald-500' : 'bg-ink-300'
}`}
aria-hidden="true"
/>
<span className="text-sm font-medium text-ink-800">
{config.configured ? 'Schlüssel hinterlegt' : 'Kein Schlüssel hinterlegt'}
</span>
{config.masked && (
<code className="rounded bg-surface px-2 py-0.5 text-xs text-ink-600">{config.masked}</code>
)}
<span className="text-xs text-ink-400">({sourceLabel})</span>
<span className="ml-auto text-xs text-ink-400">Modell {config.model}</span>
</div>
</section>
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h3 className="mb-4 text-sm font-semibold text-ink-900">Schlüssel setzen</h3>
<Field
label="Neuer Schlüssel"
hint="Wird beim Speichern übernommen und danach nicht mehr angezeigt. Ein bestehender Schlüssel wird ersetzt."
>
<input
type="password"
value={key}
onChange={(event) => setKey(event.target.value)}
placeholder="sk-proj-…"
autoComplete="off"
spellCheck={false}
className={`${inputClass} font-mono`}
/>
</Field>
<div className="mt-4 flex flex-wrap gap-2">
<PrimaryButton
type="button"
disabled={busy || !key.trim()}
onClick={() => void save({ key: key.trim() })}
>
Schlüssel speichern
</PrimaryButton>
{config.source === 'db' && (
<SecondaryButton type="button" disabled={busy} onClick={() => setAskRemove(true)}>
Schlüssel entfernen
</SecondaryButton>
)}
</div>
</section>
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h3 className="mb-4 text-sm font-semibold text-ink-900">Modell</h3>
<Field
label="Modellname"
hint={`Vorgabe ist ${config.defaultModel}. Achtung: gpt-5.6-luna erlaubt keinen eigenen „temperature"-Wert – das Backend schickt deshalb keinen.`}
>
<input
value={model}
onChange={(event) => setModel(event.target.value)}
placeholder={config.defaultModel}
spellCheck={false}
className={`${inputClass} font-mono`}
/>
</Field>
<div className="mt-4 flex flex-wrap gap-2">
<PrimaryButton
type="button"
disabled={busy || model.trim() === config.model}
onClick={() => void save({ model: model.trim() })}
>
Modell speichern
</PrimaryButton>
{model.trim() !== config.defaultModel && (
<SecondaryButton
type="button"
disabled={busy}
onClick={() => void save({ model: config.defaultModel })}
>
Zurück zur Vorgabe
</SecondaryButton>
)}
</div>
</section>
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h3 className="text-sm font-semibold text-ink-900">Verbindung prüfen</h3>
<p className="mt-1 text-sm text-ink-500">
Ein kurzer Aufruf an das Modell. Er sagt, ob Schlüssel, Modellname und Kontingent
zusammenpassen.
</p>
<div className="mt-4 flex flex-wrap items-center gap-3">
<PrimaryButton type="button" disabled={testing || !config.configured} onClick={() => void runTest()}>
{testing ? 'Prüft …' : 'Test starten'}
</PrimaryButton>
{!config.configured && (
<span className="text-xs font-medium text-ink-400">Erst einen Schlüssel hinterlegen.</span>
)}
</div>
{result && (
<p
className={`mt-4 rounded-lg border px-4 py-3 text-sm font-medium ${
result.ok
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-red-200 bg-red-50 text-red-700'
}`}
>
{result.text}
</p>
)}
</section>
<ConfirmDialog
open={askRemove}
title="Schlüssel entfernen?"
message={
config.envAvailable
? 'Der hier hinterlegte Schlüssel wird gelöscht. Danach gilt wieder der Schlüssel aus server/.env.'
: 'Der Schlüssel wird gelöscht. Danach sind CV-Auslesung, Schreibhilfe und erweiterte Suche ohne Funktion, bis ein neuer hinterlegt ist.'
}
confirmLabel="Entfernen"
onConfirm={async () => {
setAskRemove(false);
await save({ key: '' });
}}
onCancel={() => setAskRemove(false)}
/>
</div>
);
};
+200
View File
@@ -0,0 +1,200 @@
import React, { useEffect, useState } from 'react';
import { optionsApi, type OptionField } from '../../lib/api';
import { useOptions } from '../../lib/options';
import { Pill, PrimaryButton, inputClass } from '../ui/form';
import { ConfirmDialog } from '../ui/Modal';
import { useToast } from '../Toast';
/*
* Stammdaten: die gepflegten Auswahllisten.
*
* Branche und Kontaktart waren Freitext bzw. fest im Quelltext. Beides wächst
* im Betrieb - eine neue Branche, eine neue Art von Kontakt - und soll ohne
* Codeänderung gehen.
*
* Zwei Eigenschaften, die man beim Pflegen kennen sollte und die deshalb in der
* Oberfläche stehen:
*
* * Umbenennen zieht den Bestand mit. Sonst trüge ein Unternehmen eine
* Branche, die seine Auswahlliste nicht mehr kennt.
* * Entfernen geht nur, solange ein Wert nirgends benutzt wird. Deshalb steht
* hinter jedem Eintrag, wie oft er vorkommt.
*/
export const MasterData: React.FC = () => {
const { toast } = useToast();
const { reload: reloadOptions } = useOptions();
const [fields, setFields] = useState<OptionField[]>([]);
const [loading, setLoading] = useState(true);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [editing, setEditing] = useState<{ id: number; value: string } | null>(null);
const [deleting, setDeleting] = useState<{ id: number; value: string } | null>(null);
const load = async () => {
setLoading(true);
try {
const { fields: rows } = await optionsApi.list();
setFields(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
}, []);
const after = async () => {
await load();
// Die Formulare der Anwendung ziehen ihre Auswahl aus demselben Zustand.
await reloadOptions();
};
const add = async (field: string) => {
const value = (drafts[field] ?? '').trim();
if (!value) return;
try {
await optionsApi.create(field, value);
setDrafts((current) => ({ ...current, [field]: '' }));
await after();
} catch (err) {
toast(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen.', 'error');
}
};
const rename = async () => {
if (!editing) return;
try {
const { renamed } = await optionsApi.rename(editing.id, editing.value.trim());
toast(renamed > 0 ? `Umbenannt – ${renamed} Datensatz/Datensätze mitgezogen.` : 'Umbenannt.', 'success');
setEditing(null);
await after();
} catch (err) {
toast(err instanceof Error ? err.message : 'Umbenennen fehlgeschlagen.', 'error');
}
};
const remove = async () => {
if (!deleting) return;
try {
await optionsApi.remove(deleting.id);
await after();
} catch (err) {
toast(err instanceof Error ? err.message : 'Entfernen fehlgeschlagen.', 'error');
} finally {
setDeleting(null);
}
};
if (loading) return <p className="py-10 text-center text-sm text-ink-400">Wird geladen …</p>;
return (
<>
<div className="grid gap-5 lg:grid-cols-2">
{fields.map((field) => (
<section key={field.field} className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="mb-1 text-sm font-semibold text-ink-900">{field.label}</h2>
<p className="mb-3 text-xs text-ink-400">
Speist die Auswahlfelder in den Formularen. Umbenennen zieht den Bestand mit.
</p>
<ul className="divide-y divide-ink-100 rounded-lg border border-ink-200">
{field.values.length === 0 && (
<li className="px-3 py-6 text-center text-sm text-ink-400">Noch kein Eintrag.</li>
)}
{field.values.map((option) => (
<li key={option.id} className="flex items-center gap-2 px-3 py-2">
{editing?.id === option.id ? (
<>
<input
autoFocus
value={editing.value}
onChange={(event) => setEditing({ ...editing, value: event.target.value })}
onKeyDown={(event) => {
if (event.key === 'Enter') void rename();
if (event.key === 'Escape') setEditing(null);
}}
className={`${inputClass} py-1`}
/>
<button
type="button"
onClick={rename}
className="rounded-lg px-2 py-1 text-sm font-medium text-brand-700 hover:bg-brand-50"
>
Speichern
</button>
<button
type="button"
onClick={() => setEditing(null)}
className="rounded-lg px-2 py-1 text-sm text-ink-500 hover:bg-ink-100"
>
Abbrechen
</button>
</>
) : (
<>
<span className="min-w-0 flex-1 truncate text-sm text-ink-900">{option.value}</span>
<Pill tone={option.used > 0 ? 'brand' : 'ink'}>{option.used}×</Pill>
<button
type="button"
onClick={() => setEditing({ id: option.id, value: option.value })}
className="rounded-lg px-2 py-1 text-sm text-ink-500 hover:bg-ink-100 hover:text-ink-800"
>
Umbenennen
</button>
<button
type="button"
disabled={option.used > 0}
title={
option.used > 0
? `Wird noch ${option.used}× verwendet und kann nicht entfernt werden.`
: 'Entfernen'
}
onClick={() => setDeleting({ id: option.id, value: option.value })}
className="rounded-lg p-1 text-ink-400 hover:bg-ink-100 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink-400"
>
×
</button>
</>
)}
</li>
))}
</ul>
<div className="mt-3 flex gap-2">
<input
value={drafts[field.field] ?? ''}
onChange={(event) =>
setDrafts((current) => ({ ...current, [field.field]: event.target.value }))
}
onKeyDown={(event) => {
if (event.key === 'Enter') void add(field.field);
}}
placeholder="Neuer Eintrag …"
className={inputClass}
/>
<PrimaryButton
type="button"
onClick={() => add(field.field)}
disabled={!(drafts[field.field] ?? '').trim()}
>
Hinzufügen
</PrimaryButton>
</div>
</section>
))}
</div>
<ConfirmDialog
open={deleting !== null}
title="Eintrag entfernen"
message={`„${deleting?.value}" wird aus der Liste entfernt.`}
confirmLabel="Entfernen"
onConfirm={remove}
onCancel={() => setDeleting(null)}
/>
</>
);
};
+253
View File
@@ -0,0 +1,253 @@
import React, { useEffect, useState } from 'react';
import { UserManagement } from './UserManagement';
import { MasterData } from './MasterData';
import { WhisperSettings } from './WhisperSettings';
import { AiSettings } from './AiSettings';
import type { SettingsTab } from '../../lib/navigation';
import { useAuth } from '../../lib/auth';
import { useTheme } from '../../lib/theme';
import { THEME_PALETTES, THEME_PALETTE_LABELS } from '../../types';
import { authApi } from '../../lib/api';
import { AvatarPicker } from '../ui/AvatarPicker';
import { Field, PrimaryButton, inputClass, labelClass } from '../ui/form';
import { useToast } from '../Toast';
interface SettingsProps {
/** Reiter, der beim Oeffnen gezeigt werden soll - sonst der uebliche. */
initialTab?: SettingsTab | null;
/** Meldet zurueck, dass der Wunsch erfuellt ist. */
onTabConsumed?: () => void;
}
export const Settings: React.FC<SettingsProps> = ({ initialTab, onTabConsumed }) => {
const { user, isAdmin, updateUser } = useAuth();
const { theme, setTheme, mode, setMode, followsSystem, resetMode } = useTheme();
const { toast } = useToast();
const [tab, setTab] = useState<SettingsTab>(initialTab ?? (isAdmin ? 'benutzer' : 'konto'));
/*
* Ein von aussen gewuenschter Reiter gewinnt - aber nur einmal. Bliebe der
* Wunsch stehen, koennte man den Reiter danach nicht mehr wechseln.
*/
useEffect(() => {
if (!initialTab) return;
setTab(initialTab);
onTabConsumed?.();
}, [initialTab, onTabConsumed]);
const [color, setColor] = useState(user?.color ?? '#c1862f');
const [avatar, setAvatar] = useState<string | null>(user?.avatar ?? null);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const saveProfile = async () => {
try {
const { user: updated } = await authApi.updateMe({ color, avatar });
updateUser(updated);
toast('Konto gespeichert.', 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
}
};
const changePassword = async () => {
try {
await authApi.changePassword(currentPassword, newPassword);
setCurrentPassword('');
setNewPassword('');
toast('Passwort geändert.', 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Ändern fehlgeschlagen.', 'error');
}
};
return (
<div className="space-y-5">
<nav className="flex gap-1 border-b border-ink-200">
{isAdmin && (
<button
type="button"
onClick={() => setTab('benutzer')}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
tab === 'benutzer' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
}`}
>
Nutzerverwaltung
</button>
)}
{isAdmin && (
<button
type="button"
onClick={() => setTab('stammdaten')}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
tab === 'stammdaten' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
}`}
>
Stammdaten
</button>
)}
{isAdmin && (
<button
type="button"
onClick={() => setTab('ki')}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
tab === 'ki' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
}`}
>
KI
</button>
)}
<button
type="button"
onClick={() => setTab('konto')}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
tab === 'konto' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
}`}
>
Mein Konto
</button>
{/* Diktat richtet jeder auf seinem eigenen Geraet ein - keine Rolle noetig. */}
<button
type="button"
onClick={() => setTab('diktat')}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
tab === 'diktat' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
}`}
>
Diktat
</button>
</nav>
{tab === 'ki' && isAdmin && <AiSettings />}
{tab === 'diktat' && <WhisperSettings />}
{tab === 'benutzer' && isAdmin && <UserManagement />}
{tab === 'stammdaten' && isAdmin && <MasterData />}
{tab === 'konto' && (
<div className="grid gap-5 lg:grid-cols-2">
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="mb-4 text-sm font-semibold text-ink-900">Profil</h2>
<AvatarPicker name={user?.name ?? '?'} value={avatar} onChange={setAvatar} />
<div className="mt-4">
<label className={labelClass}>Persönliche Farbe</label>
<div className="flex items-center gap-3">
<input
type="color"
value={color}
onChange={(event) => setColor(event.target.value)}
className="h-9 w-16 cursor-pointer rounded border border-ink-200 bg-surface"
/>
<span className="text-sm text-ink-500">{color}</span>
</div>
<p className="mt-1 text-xs text-ink-400">
Trägt Initialen-Avatare und die Punkte in der Historie.
</p>
</div>
<PrimaryButton type="button" onClick={saveProfile} className="mt-4">
Speichern
</PrimaryButton>
</section>
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="mb-4 text-sm font-semibold text-ink-900">Darstellung</h2>
<label className={labelClass}>Modus</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setMode('light')}
className={`rounded-lg border px-3 py-1.5 text-sm ${
!followsSystem && mode === 'light'
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-ink-200 text-ink-600'
}`}
>
Hell
</button>
<button
type="button"
onClick={() => setMode('dark')}
className={`rounded-lg border px-3 py-1.5 text-sm ${
!followsSystem && mode === 'dark'
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-ink-200 text-ink-600'
}`}
>
Dunkel
</button>
<button
type="button"
onClick={resetMode}
className={`rounded-lg border px-3 py-1.5 text-sm ${
followsSystem ? 'border-brand-500 bg-brand-50 text-brand-700' : 'border-ink-200 text-ink-600'
}`}
>
Dem System folgen
</button>
</div>
<label className={`${labelClass} mt-4`}>Design</label>
<ul className="space-y-2">
{THEME_PALETTES.map((value) => (
<li key={value}>
<button
type="button"
onClick={() => setTheme(value)}
className={`w-full rounded-lg border px-3 py-2 text-left text-sm ${
theme === value
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-ink-200 text-ink-600 hover:bg-ink-50'
}`}
>
<span className="font-medium">{THEME_PALETTE_LABELS[value].name}</span>
<span className="ml-2 text-xs text-ink-400">{THEME_PALETTE_LABELS[value].hint}</span>
</button>
</li>
))}
</ul>
<p className="mt-2 text-xs text-ink-400">
Dasselbe steht oben rechts im Kopf – dort auch der Umschalter zwischen hell und dunkel.
</p>
</section>
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="mb-4 text-sm font-semibold text-ink-900">Passwort ändern</h2>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Aktuelles Passwort">
<input
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
className={inputClass}
/>
</Field>
<Field label="Neues Passwort" hint="Mindestens 8 Zeichen.">
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
className={inputClass}
/>
</Field>
</div>
<PrimaryButton
type="button"
onClick={changePassword}
disabled={!currentPassword || newPassword.length < 8}
className="mt-4"
>
Passwort ändern
</PrimaryButton>
</section>
</div>
)}
</div>
);
};
+269
View File
@@ -0,0 +1,269 @@
import React, { useEffect, useState } from 'react';
import type { AppUser } from '../../types';
import { userColor } from '../../types';
import { usersApi } from '../../lib/api';
import { Modal, ConfirmDialog } from '../ui/Modal';
import { Avatar } from '../ui/Avatar';
import { Field, Pill, PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
import { useAuth } from '../../lib/auth';
import { useToast } from '../Toast';
export const UserManagement: React.FC = () => {
const { user: me } = useAuth();
const { toast } = useToast();
const [users, setUsers] = useState<AppUser[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<AppUser | null | undefined>(undefined);
const [deleting, setDeleting] = useState<AppUser | null>(null);
const load = async () => {
setLoading(true);
try {
const { users: rows } = await usersApi.list();
setUsers(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
}, []);
const remove = async () => {
if (!deleting) return;
try {
await usersApi.remove(deleting.id);
toast('Benutzer gelöscht.', 'success');
void load();
} catch (err) {
toast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
} finally {
setDeleting(null);
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-ink-400">{users.length} Benutzer</p>
<PrimaryButton type="button" onClick={() => setEditing(null)}>
Neuer Benutzer
</PrimaryButton>
</div>
{loading ? (
<p className="py-10 text-center text-sm text-ink-400">Wird geladen …</p>
) : (
<div className="overflow-x-auto rounded-xl border border-ink-200 bg-surface">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-ink-200 text-left text-xs uppercase tracking-wide text-ink-500">
<th className="px-4 py-2.5 font-semibold">Name</th>
<th className="px-4 py-2.5 font-semibold">Benutzername</th>
<th className="px-4 py-2.5 font-semibold">E-Mail</th>
<th className="px-4 py-2.5 font-semibold">Rolle</th>
<th className="px-4 py-2.5 font-semibold">Status</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{users.map((user) => (
<tr key={user.id} className="hover:bg-ink-50">
<td className="px-4 py-2.5">
<span className="flex items-center gap-2">
<Avatar name={user.name} src={user.avatar} size={28} color={userColor(user)} />
<span className="font-medium text-ink-900">{user.name}</span>
</span>
</td>
<td className="px-4 py-2.5 text-ink-600">{user.username}</td>
<td className="px-4 py-2.5 text-ink-600">{user.email || '–'}</td>
<td className="px-4 py-2.5">
<Pill tone={user.role === 'admin' ? 'brand' : 'ink'}>
{user.role === 'admin' ? 'Administrator' : 'Benutzer'}
</Pill>
</td>
<td className="px-4 py-2.5">
<Pill tone={user.is_active ? 'emerald' : 'red'}>
{user.is_active ? 'aktiv' : 'deaktiviert'}
</Pill>
</td>
<td className="px-4 py-2.5 text-right">
<button
type="button"
onClick={() => setEditing(user)}
className="rounded-lg px-2 py-1 text-sm font-medium text-ink-600 hover:bg-ink-100"
>
Bearbeiten
</button>
{user.id !== me?.id && (
<button
type="button"
onClick={() => setDeleting(user)}
className="ml-1 rounded-lg px-2 py-1 text-sm font-medium text-red-600 hover:bg-red-50"
>
Löschen
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editing !== undefined && (
<UserModal
user={editing}
onClose={() => setEditing(undefined)}
onSaved={() => {
void load();
}}
/>
)}
<ConfirmDialog
open={deleting !== null}
title="Benutzer löschen"
message={`${deleting?.name} wird gelöscht. Historieneinträge bleiben erhalten, verlieren aber die Zuordnung.`}
onConfirm={remove}
onCancel={() => setDeleting(null)}
/>
</div>
);
};
const UserModal: React.FC<{
user: AppUser | null;
onClose: () => void;
onSaved: () => void;
}> = ({ user, onClose, onSaved }) => {
const { toast } = useToast();
const [form, setForm] = useState({
username: user?.username ?? '',
name: user?.name ?? '',
email: user?.email ?? '',
role: user?.role ?? 'user',
password: '',
is_active: user?.is_active ?? true,
color: user?.color ?? '#c1862f',
});
const [saving, setSaving] = useState(false);
const set = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) =>
setForm((current) => ({ ...current, [key]: value }));
const save = async () => {
setSaving(true);
try {
if (user) {
const body: Record<string, unknown> = {
name: form.name,
email: form.email,
role: form.role,
is_active: form.is_active,
color: form.color,
};
// Ein leeres Feld heisst "Passwort nicht anfassen" - nicht "leeres
// Passwort setzen".
if (form.password) body.password = form.password;
await usersApi.update(user.id, body);
} else {
await usersApi.create(form);
}
toast('Gespeichert.', 'success');
onSaved();
onClose();
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setSaving(false);
}
};
return (
<Modal
open
onClose={onClose}
title={user ? `Benutzer ${user.name}` : 'Neuer Benutzer'}
footer={
<>
<SecondaryButton type="button" onClick={onClose}>
Abbrechen
</SecondaryButton>
<PrimaryButton type="button" onClick={save} disabled={saving}>
{saving ? 'Speichern …' : 'Speichern'}
</PrimaryButton>
</>
}
>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Benutzername *">
<input
value={form.username}
onChange={(event) => set('username', event.target.value)}
disabled={Boolean(user)}
className={inputClass}
/>
</Field>
<Field label="Name *">
<input value={form.name} onChange={(event) => set('name', event.target.value)} className={inputClass} />
</Field>
<Field label="E-Mail">
<input
type="email"
value={form.email ?? ''}
onChange={(event) => set('email', event.target.value)}
className={inputClass}
/>
</Field>
<Field label="Rolle">
<select
value={form.role}
onChange={(event) => set('role', event.target.value as 'admin' | 'user')}
className={inputClass}
>
<option value="user">Benutzer</option>
<option value="admin">Administrator</option>
</select>
</Field>
<Field
label={user ? 'Neues Passwort' : 'Passwort *'}
hint={user ? 'Leer lassen, um das Passwort nicht zu ändern.' : 'Mindestens 8 Zeichen.'}
>
<input
type="password"
value={form.password}
onChange={(event) => set('password', event.target.value)}
autoComplete="new-password"
className={inputClass}
/>
</Field>
<Field label="Farbe">
<input
type="color"
value={form.color}
onChange={(event) => set('color', event.target.value)}
className="h-[38px] w-full cursor-pointer rounded-lg border border-ink-200 bg-surface"
/>
</Field>
</div>
{user && (
<label className="mt-4 flex items-center gap-2 text-sm text-ink-700">
<input
type="checkbox"
checked={form.is_active}
onChange={(event) => set('is_active', event.target.checked)}
className="h-4 w-4 accent-[var(--color-brand-600)]"
/>
Konto ist aktiv
</label>
)}
</Modal>
);
};
+250
View File
@@ -0,0 +1,250 @@
import React, { useEffect, useState } from 'react';
import { ConfirmDialog } from '../ui/Modal';
import { labelClass } from '../ui/form';
import { useToast } from '../Toast';
import { useRecorder } from '../../lib/dictation';
import { WHISPER_MODEL, useWhisper } from '../../lib/whisper';
import { fetchModelSize, formatSize } from '../../lib/whisperModels';
/*
* Reiter "Diktat" in den Einstellungen - für jede Rolle sichtbar, denn jeder
* richtet das Diktat auf seinem eigenen Gerät ein.
*
* Einzustellen gibt es hier bewusst nichts: Modell, Genauigkeit, Rechenweg und
* Sprache stehen fest (siehe lib/whisper.tsx). Übrig bleibt, was wirklich
* gebraucht wird - einmal herunterladen, je Sitzung einmal aktivieren. Und der
* Weg, es wieder loszuwerden.
*/
export const WhisperSettings: React.FC = () => {
const { toast } = useToast();
const {
status,
phase,
progress,
progressLabel,
error,
isCached,
capabilities,
load,
clearCache,
isSupported,
} = useWhisper();
const [testText, setTestText] = useState('');
const [askClear, setAskClear] = useState(false);
/** Downloadgröße, direkt bei Hugging Face erfragt. */
const [sizeLabel, setSizeLabel] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchModelSize(WHISPER_MODEL)
.then((size) => {
if (!cancelled && size) setSizeLabel(formatSize(size.totalBytes));
})
.catch(() => {
// Ohne Angabe läuft es genauso - nur ohne Zahl im Text.
});
return () => {
cancelled = true;
};
}, []);
const recorder = useRecorder(
(text) => {
setTestText(text);
toast('Erkennung abgeschlossen.', 'success');
},
(message) => toast(message, 'error')
);
const handleLoad = async () => {
try {
await load();
toast('Diktat ist bereit.', 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
}
};
const isLoading = status === 'loading';
return (
<div className="space-y-5">
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="text-base font-semibold text-ink-900">Diktat mit Whisper</h2>
<p className="mt-2 max-w-3xl text-sm text-ink-500">
Die Spracherkennung läuft vollständig in diesem Browser. Aufnahmen verlassen das Gerät
nicht und werden nirgends gespeichert – weder auf dem ICON-Server noch bei einem
Dienstleister.
</p>
<p className="mt-2 max-w-3xl text-sm text-ink-500">
Der Download fällt einmal je Browser an; das Modell bleibt danach hier liegen. Aktiviert
wird es bei jeder Anmeldung mit einem Klick – das geschieht bewusst nicht von selbst:
das Einrichten der Erkennung beansprucht für einige Sekunden die Grafikkarte, und das
soll niemanden mitten in der Arbeit treffen.
</p>
</section>
{/* Zustand und die beiden Aktionen */}
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="min-w-0">
<h3 className="text-base font-semibold text-ink-900">
{status === 'ready'
? 'Diktat ist bereit'
: isLoading
? phase === 'init'
? 'Wird eingerichtet …'
: 'Wird geladen …'
: status === 'error'
? 'Laden fehlgeschlagen'
: 'Diktat noch nicht eingerichtet'}
</h3>
<p className="text-sm text-ink-500">
{status === 'ready'
? 'Die Mikrofon-Knöpfe in den Formularen sind aktiv.'
: isLoading
? phase === 'init'
? 'Gleich fertig.'
: progressLabel || 'Download startet …'
: isCached
? 'Das Modell liegt in diesem Browser – ein Klick aktiviert es für diese Sitzung.'
: `Einmalig werden ${sizeLabel ?? 'die Modelldateien'} geladen – danach bleibt es in diesem Browser.`}
</p>
</div>
<div className="flex flex-wrap gap-2">
{status !== 'ready' && (
<button
type="button"
onClick={() => void handleLoad()}
disabled={isLoading || !isSupported}
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50"
>
{isLoading
? 'Lädt …'
: isCached
? 'Diktat aktivieren'
: `Sprachmodell laden${sizeLabel ? ` (${sizeLabel})` : ''}`}
</button>
)}
{(isCached || status === 'ready') && (
<button
type="button"
onClick={() => setAskClear(true)}
className="rounded-lg border border-ink-200 px-4 py-2 text-sm font-medium text-ink-700 hover:border-red-300 hover:text-red-600"
>
Sprachmodell löschen
</button>
)}
</div>
</div>
{isLoading && (
<p className="mt-4 rounded-lg border border-ink-200 bg-ink-50 px-4 py-3 text-sm text-ink-600">
Während das Modell eingerichtet wird, kann die Oberfläche kurz stocken. Das legt sich,
sobald oben „Diktat ist bereit" steht.
</p>
)}
{isLoading && (
<div className="mt-4 h-2 overflow-hidden rounded-full bg-ink-100">
<div
className="h-full rounded-full bg-brand-600 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
)}
{error && (
<p className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
{error}
</p>
)}
{/* Nur melden, was den Benutzer betrifft - sonst Stille. */}
{!isSupported && (
<p className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
Auf diesem Gerät ist kein Diktat möglich – es fehlt ein Mikrofon oder eine sichere
Verbindung. Die Mikrofon-Knöpfe bleiben ausgeblendet.
</p>
)}
{isSupported && !capabilities.webgpu && (
<p className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
Dieses Gerät hat keine nutzbare Grafikbeschleunigung. Das Diktat funktioniert, die
Erkennung dauert aber deutlich länger.
</p>
)}
{status === 'ready' && !isCached && (
<p className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
Dieser Browser hat das Modell nicht behalten – beim nächsten Start muss es erneut
geladen werden. Ursache ist meist ein privates Fenster oder eine
Speicherbeschränkung.
</p>
)}
</section>
{/* Probe */}
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h3 className="text-base font-semibold text-ink-900">Probe sprechen</h3>
<p className="mt-1 text-sm text-ink-500">
Kurz etwas diktieren, um zu prüfen, ob alles sitzt.
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={recorder.state === 'recording' ? recorder.stop : () => void recorder.start()}
disabled={status !== 'ready' || recorder.state === 'transcribing'}
className={`rounded-lg px-4 py-2 text-sm font-semibold disabled:opacity-50 ${
recorder.state === 'recording'
? 'border border-red-300 bg-red-50 text-red-700'
: 'bg-brand-600 text-white hover:bg-brand-700'
}`}
>
{recorder.state === 'recording'
? `Aufnahme beenden (${recorder.seconds} s)`
: recorder.state === 'transcribing'
? 'Erkenne …'
: 'Aufnahme starten'}
</button>
{recorder.state === 'recording' && (
<button
type="button"
onClick={recorder.cancel}
className="rounded-lg border border-ink-200 px-4 py-2 text-sm font-medium text-ink-700 hover:bg-ink-100"
>
Verwerfen
</button>
)}
{status !== 'ready' && (
<span className="text-xs font-medium text-ink-400">Erst das Sprachmodell laden.</span>
)}
</div>
<div className="mt-4">
<label className={labelClass}>Erkannter Text</label>
<p className="min-h-16 whitespace-pre-wrap rounded-lg border border-ink-200 bg-ink-50 px-4 py-3 text-sm text-ink-700">
{testText || <span className="text-ink-400">Noch keine Probe.</span>}
</p>
</div>
</section>
<ConfirmDialog
open={askClear}
title="Sprachmodell löschen?"
message="Das Modell wird aus diesem Browser entfernt. Für ein weiteres Diktat muss es erneut geladen werden."
confirmLabel="Löschen"
onConfirm={async () => {
await clearCache();
setAskClear(false);
toast('Sprachmodell entfernt.', 'success');
}}
onCancel={() => setAskClear(false)}
/>
</div>
);
};
+175
View File
@@ -0,0 +1,175 @@
import React, { useEffect, useMemo, useState } from 'react';
import { TENDER_STAGES, formatDate, type Tender, type TenderStage } from '../../types';
import { tendersApi } from '../../lib/api';
import { Avatar } from '../ui/Avatar';
import { Pill, inputClass } from '../ui/form';
import { useNavigation } from '../../lib/navigation';
import { useToast } from '../Toast';
import { useKanbanDrag, cardClass, columnClass } from '../../lib/kanban';
/*
* "Live!" - das Kanban über die Phasen einer Ausschreibung.
*
* Es sind dieselben Daten wie in der Ansicht Ausschreibungen, nur nach Phase
* sortiert. Anders als früher ist die Ansicht bearbeitbar: eine Kachel lässt
* sich in eine andere Spalte ziehen, und der Wechsel landet als eigener
* Eintrag in der Historie. Ein Klick öffnet die Ausschreibung in ihrer Liste.
*
* Gezeigt wird bewusst nur, was aktiv ist - ein Kanban mit archivierten
* Einträgen wäre kein Arbeitsvorrat mehr.
*/
export const LiveBoard: React.FC<{ onChanged: () => void }> = ({ onChanged }) => {
const { toast } = useToast();
const { open } = useNavigation();
const [tenders, setTenders] = useState<Tender[]>([]);
const [loading, setLoading] = useState(true);
const [term, setTerm] = useState('');
const [showInactive, setShowInactive] = useState(false);
const load = async () => {
setLoading(true);
try {
const { tenders: rows } = await tendersApi.list();
setTenders(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
}, []);
const visible = useMemo(() => {
const q = term.trim().toLowerCase();
return tenders.filter((tender) => {
if (!showInactive && tender.status !== 'Aktiv') return false;
if (!q) return true;
return [tender.title, tender.source, tender.company_name]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(q);
});
}, [tenders, term, showInactive]);
const moveTo = async (id: number, stage: TenderStage) => {
const tender = tenders.find((item) => item.id === id);
if (!tender || tender.stage === stage) return;
// Sofort umsetzen, damit die Kachel nicht am Zeiger klebt; bei einem
// Fehler wird der Stand neu geladen.
setTenders((current) => current.map((item) => (item.id === id ? { ...item, stage } : item)));
try {
await tendersApi.update(id, { stage });
onChanged();
} catch (err) {
toast(err instanceof Error ? err.message : 'Verschieben fehlgeschlagen.', 'error');
void load();
}
};
const drag = useKanbanDrag<TenderStage>((id, stage) => void moveTo(id, stage));
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder="Im Board filtern …"
className={`${inputClass} max-w-xs`}
/>
<label className="flex items-center gap-2 text-sm text-ink-600">
<input
type="checkbox"
checked={showInactive}
onChange={(event) => setShowInactive(event.target.checked)}
className="h-4 w-4 accent-[var(--color-brand-600)]"
/>
Auch nicht aktive zeigen
</label>
<span className="ml-auto text-sm text-ink-400">{visible.length} im Board</span>
</div>
{loading ? (
<p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>
) : (
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-slim">
{TENDER_STAGES.map((stage) => {
const items = visible.filter((tender) => tender.stage === stage);
return (
<section
key={stage}
{...drag.columnProps(stage)}
className={`flex w-72 shrink-0 flex-col rounded-xl border transition-colors ${columnClass(
drag.hoverColumn === stage
)}`}
>
<header className="flex items-center justify-between border-b border-ink-200 px-3 py-2">
<h3 className="text-sm font-semibold text-ink-800">{stage}</h3>
<span className="rounded-full bg-ink-100 px-2 py-0.5 text-xs font-medium text-ink-500">
{items.length}
</span>
</header>
<ul className="flex-1 space-y-2 p-2">
{items.map((tender) => (
<li key={tender.id}>
<div
{...drag.cardProps(tender.id)}
role="button"
tabIndex={0}
onClick={() => open('tender', tender.id)}
onKeyDown={(event) => {
if (event.key === 'Enter') open('tender', tender.id);
}}
className={`w-full cursor-grab rounded-lg border border-ink-200 bg-surface p-3 text-left shadow-card active:cursor-grabbing ${cardClass(
drag.draggedId === tender.id
)}`}
>
<p className="line-clamp-2 text-sm font-medium text-ink-900">{tender.title}</p>
{tender.company_name && (
<span className="mt-2 flex items-center gap-2">
<Avatar
name={tender.company_name}
src={tender.company_logo}
size={20}
square
/>
<span className="truncate text-xs text-ink-600">{tender.company_name}</span>
</span>
)}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{tender.online && <Pill tone="emerald">online</Pill>}
{tender.source && <Pill>{tender.source}</Pill>}
{(tender.contacts?.length ?? 0) > 0 && (
<Pill tone="brand">{tender.contacts!.length} Experten</Pill>
)}
</div>
{tender.deadline && (
<p className="mt-2 text-xs text-ink-400">Frist {formatDate(tender.deadline)}</p>
)}
</div>
</li>
))}
{items.length === 0 && (
<li className="rounded-lg border border-dashed border-ink-200 px-3 py-6 text-center text-xs text-ink-400">
Hierher ziehen
</li>
)}
</ul>
</section>
);
})}
</div>
)}
</div>
);
};
+143
View File
@@ -0,0 +1,143 @@
import React, { useEffect, useState } from 'react';
import { aiApi, type AiTask } from '../../lib/api';
import { RichTextEditor } from '../ui/RichTextEditor';
import { DictateButton, appendParagraph } from '../ui/Dictate';
import { inputClass } from '../ui/form';
import { useToast } from '../Toast';
/*
* Der Ausschreibungstext als WYSIWYG-Feld mit Schreibhilfe.
*
* Der Inhalt ist HTML und liegt in `icon_tenders.notes` - dasselbe Feld, das
* die ICON Website liest. Deshalb wird hier nichts umformatiert, was nicht
* jemand ausdrücklich auslöst.
*
* Die KI ersetzt den Text nicht heimlich: das Ergebnis landet erst im Editor,
* wenn es da ist, und der vorherige Stand lässt sich mit einem Klick
* zurückholen. Ohne hinterlegten Schlüssel bleibt die Leiste leer.
*/
const AI_TASKS: Array<{ task: AiTask; label: string; needsText: boolean }> = [
{ task: 'write', label: 'Text erzeugen', needsText: false },
{ task: 'improve', label: 'Verbessern', needsText: true },
{ task: 'shorten', label: 'Kürzen', needsText: true },
{ task: 'bullets', label: 'Stichpunkte', needsText: true },
{ task: 'profile', label: 'Anforderungsprofil', needsText: true },
];
interface TenderTextProps {
value: string;
onChange: (value: string) => void;
minHeight?: number;
/** Wird vor dem Wechsel in die Einstellungen gerufen - schließt den Dialog. */
onBeforeNavigate?: () => void;
}
export const TenderText: React.FC<TenderTextProps> = ({
value,
onChange,
minHeight = 320,
onBeforeNavigate,
}) => {
const { toast } = useToast();
const [ai, setAi] = useState<{ configured: boolean; model: string } | null>(null);
const [busy, setBusy] = useState<AiTask | null>(null);
const [prompt, setPrompt] = useState('');
const [previous, setPrevious] = useState<string | null>(null);
useEffect(() => {
aiApi
.status()
.then(setAi)
.catch(() => setAi({ configured: false, model: '' }));
}, []);
const run = async (task: AiTask) => {
setBusy(task);
try {
const { text } = await aiApi.text(task, value, prompt);
setPrevious(value);
onChange(text);
setPrompt('');
} catch (err) {
toast(err instanceof Error ? err.message : 'Die KI hat nicht geantwortet.', 'error');
} finally {
setBusy(null);
}
};
const hasText = stripHtml(value).length > 20;
return (
<div className="space-y-2">
<RichTextEditor
value={value}
onChange={onChange}
minHeight={minHeight}
placeholder="Details zur Ausschreibung hier einfügen …"
actions={
/*
Das Diktat steht unabhängig von der KI zur Verfügung: es rechnet im
Browser und braucht keinen Schlüssel.
*/
<>
<DictateButton
label="Ausschreibungstext"
compact
onBeforeNavigate={onBeforeNavigate}
onText={(text) => onChange(appendParagraph(value, text))}
/>
{ai?.configured && (
<>
{AI_TASKS.filter((item) => !item.needsText || hasText).map((item) => (
<button
key={item.task}
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => void run(item.task)}
disabled={busy !== null}
title={`${item.label} – ${ai.model}`}
className="rounded px-2 py-1 text-xs font-medium text-brand-700 hover:bg-brand-50 disabled:opacity-50"
>
{busy === item.task ? '…' : item.label}
</button>
))}
{previous !== null && (
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
onChange(previous);
setPrevious(null);
}}
className="rounded px-2 py-1 text-xs font-medium text-ink-500 hover:bg-ink-100"
>
Zurück
</button>
)}
</>
)}
</>
}
/>
{ai?.configured && (
<input
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="Vorgabe für die KI, z. B. „Interim PL SAP S/4HANA, Bank, 12 Monate, 80 % remote“"
className={`${inputClass} text-xs`}
/>
)}
</div>
);
};
/** Reiner Text aus dem HTML - nur, um zu entscheiden, ob schon etwas dasteht. */
function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
+726
View File
@@ -0,0 +1,726 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Company, Contact, Tender, TenderStatus } from '../../types';
import { tendersApi } from '../../lib/api';
import { Modal, ConfirmDialog } from '../ui/Modal';
import { Avatar } from '../ui/Avatar';
import { Field, Pill, PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
import { TenderText } from './TenderText';
import { toEditorHtml } from '../../lib/richText';
import { useToast } from '../Toast';
interface TendersProps {
companies: Company[];
contacts: Contact[];
onChanged: () => void;
openId: number | null;
onOpenHandled: () => void;
/** true = der Unterpunkt "Archiv"; sonst der laufende Bestand. */
archived?: boolean;
}
/*
* Ausschreibungen - wieder so aufgebaut wie im ursprünglichen ICON CRM.
*
* Eine Tabelle, deren Zeile sich aufklappt: links die zugewiesenen Experten und
* der Kundenkontakt, rechts der Ausschreibungstext im WYSIWYG-Feld.
*
* Zwei Dinge sind hier keine Kosmetik, sondern hängen an der ICON Website:
*
* * Der Ausschreibungstext steht in `notes` (HTML). Die Website liest genau
* dieses Feld - deshalb liegt der Editor darauf und nicht auf `details`.
* * Der Schalter "Online" entscheidet, ob eine Ausschreibung auf der Website
* erscheint. Er sitzt deshalb direkt in der Liste, einen Klick entfernt.
*
* Das Kanban nach Phasen ist nicht hier, sondern in der Ansicht "Live!" - so
* wie zuvor auch.
*/
const STATUS_OPTIONS: TenderStatus[] = ['Aktiv', 'Inaktiv', 'Ausgelaufen', 'Archiviert'];
/** "Inaktiv" heißt in der Oberfläche seit jeher "Nicht aktiv". */
const statusLabel = (status: TenderStatus) => (status === 'Inaktiv' ? 'Nicht aktiv' : status);
const STATUS_TONE: Record<TenderStatus, string> = {
Aktiv: 'emerald',
Inaktiv: 'red',
Ausgelaufen: 'amber',
Archiviert: 'ink',
};
export const Tenders: React.FC<TendersProps> = ({
companies,
contacts,
onChanged,
openId,
onOpenHandled,
archived = false,
}) => {
const { toast } = useToast();
const [tenders, setTenders] = useState<Tender[]>([]);
const [loading, setLoading] = useState(true);
const [term, setTerm] = useState('');
const [expandedId, setExpandedId] = useState<number | null>(null);
const [editing, setEditing] = useState<Tender | null | undefined>(undefined);
const [deleting, setDeleting] = useState<Tender | null>(null);
const [assigning, setAssigning] = useState<{ tender: Tender; what: 'contacts' | 'company' } | null>(
null
);
const load = async () => {
setLoading(true);
try {
const { tenders: rows } = await tendersApi.list();
setTenders(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;
setExpandedId(openId);
onOpenHandled();
}, [openId, onOpenHandled]);
const visible = useMemo(() => {
const q = term.trim().toLowerCase();
return tenders.filter((tender) => {
/*
* Archiviertes steht im eigenen Unterpunkt. Damit braucht die Liste
* keinen Statusfilter mehr - was hier steht, ist der laufende Bestand.
*/
if (archived !== (tender.status === 'Archiviert')) return false;
if (!q) return true;
return [tender.title, tender.source, tender.company_name, tender.notes]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(q);
});
}, [tenders, term, archived]);
/** Lokal sofort umsetzen, im Hintergrund speichern. */
const patch = async (tender: Tender, body: Record<string, unknown>, message?: string) => {
setTenders((current) =>
current.map((item) => (item.id === tender.id ? { ...item, ...body } : item))
);
try {
await tendersApi.update(tender.id, body);
if (message) toast(message, 'success');
onChanged();
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
void load();
}
};
/*
* Der Ausschreibungstext speichert sich selbst - wie im Original. Ohne die
* Verzögerung ginge bei jedem Tastendruck eine Anfrage heraus.
*/
const notesTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const updateNotes = useCallback(
(id: number, notes: string) => {
setTenders((current) => current.map((item) => (item.id === id ? { ...item, notes } : item)));
if (notesTimer.current) clearTimeout(notesTimer.current);
notesTimer.current = setTimeout(() => {
tendersApi.update(id, { notes }).catch(() => {
toast('Der Ausschreibungstext konnte nicht gespeichert werden.', 'error');
});
}, 800);
},
[toast]
);
// Beim Verlassen der Ansicht darf kein Text im Zwischenspeicher hängen bleiben.
useEffect(() => () => {
if (notesTimer.current) clearTimeout(notesTimer.current);
}, []);
const toggleContact = (tender: Tender, contactId: number) => {
const current = (tender.contacts ?? []).map((contact) => contact.id);
const next = current.includes(contactId)
? current.filter((id) => id !== contactId)
: [...current, contactId];
const chosen = contacts.filter((contact) => next.includes(contact.id));
setTenders((list) =>
list.map((item) =>
item.id === tender.id
? {
...item,
contacts: chosen.map((contact) => ({
id: contact.id,
name: contact.name,
position: contact.position,
avatar: contact.avatar,
status: contact.status,
})),
contact_count: next.length,
}
: item
)
);
tendersApi.update(tender.id, { contact_ids: next }).then(onChanged).catch(() => void load());
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder="Ausschreibungen filtern …"
className={`${inputClass} max-w-xs`}
/>
<span className="ml-auto text-sm text-ink-400">
{visible.length} von {tenders.length}
</span>
{!archived && (
<PrimaryButton type="button" onClick={() => setEditing(null)}>
Ausschreibung hinzufügen
</PrimaryButton>
)}
</div>
{loading ? (
<p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>
) : (
<div className="overflow-x-auto rounded-xl border border-ink-200 bg-surface">
<table className="w-full min-w-[1000px] text-left text-sm">
<thead>
<tr className="border-b border-ink-200 text-xs uppercase tracking-wide text-ink-500">
<th className="px-4 py-2.5 font-semibold">Titel</th>
<th className="px-4 py-2.5 font-semibold">Kunde / Kontakt</th>
<th className="px-4 py-2.5 font-semibold">Quelle</th>
<th className="px-4 py-2.5 text-center font-semibold">Link</th>
<th className="px-4 py-2.5 text-center font-semibold">Experten</th>
<th className="px-4 py-2.5 text-center font-semibold">Online</th>
<th className="px-4 py-2.5 font-semibold">Status</th>
<th className="w-24 px-4 py-2.5" />
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{visible.map((tender) => (
<React.Fragment key={tender.id}>
<tr
onClick={() => setExpandedId(expandedId === tender.id ? null : tender.id)}
className={`cursor-pointer hover:bg-ink-50 ${
expandedId === tender.id ? 'bg-brand-50' : ''
}`}
>
<td className="px-4 py-3 font-semibold text-ink-900">{tender.title}</td>
<td className="px-4 py-3">
{tender.company_name ? (
<span className="flex items-center gap-2">
<Avatar name={tender.company_name} src={tender.company_logo} size={24} square />
<span className="text-ink-700">{tender.company_name}</span>
</span>
) : (
<span className="text-xs italic text-ink-400">Nicht zugewiesen</span>
)}
</td>
<td className="px-4 py-3 text-ink-600">{tender.source || '–'}</td>
<td className="px-4 py-3 text-center">
{tender.link && (
<a
href={tender.link}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
title="Ausschreibung öffnen"
className="inline-block text-brand-600 hover:text-brand-700"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</td>
<td className="px-4 py-3 text-center">
<span className="rounded-full border border-ink-200 bg-ink-50 px-3 py-1 text-sm font-semibold text-ink-700">
{tender.contacts?.length ?? tender.contact_count ?? 0}
</span>
</td>
<td className="px-4 py-3 text-center">
<button
type="button"
title={
tender.online
? 'Online auf der ICON Website – klicken zum Offline nehmen'
: 'Offline – klicken zum Online stellen'
}
onClick={(event) => {
event.stopPropagation();
void patch(
tender,
{ online: !tender.online },
tender.online ? 'Ausschreibung offline genommen' : 'Ausschreibung ist jetzt online'
);
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
tender.online ? 'bg-emerald-500' : 'bg-ink-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
tender.online ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</td>
<td className="px-4 py-3">
<Pill tone={STATUS_TONE[tender.status]}>{statusLabel(tender.status)}</Pill>
</td>
<td className="px-4 py-3">
<span className="flex items-center justify-end gap-1">
{/* Archivieren bzw. zurückholen - der Aktenschrank. */}
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void patch(
tender,
{ status: archived ? 'Aktiv' : 'Archiviert' },
archived ? 'Zurückgeholt' : 'Archiviert'
);
}}
aria-label={archived ? 'Zurückholen' : 'Archivieren'}
title={archived ? 'Zurückholen' : 'Ins Archiv verschieben'}
className="rounded-lg p-1.5 text-ink-400 hover:bg-ink-100 hover:text-brand-700"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d={
archived
? 'M4 4h16v6H4V4Zm0 10h16v6H4v-6Zm8-9v-3m0 0-2 2m2-2 2 2'
: 'M4 4h16v6H4V4Zm0 10h16v6H4v-6Zm5-3h6'
}
/>
</svg>
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setEditing(tender);
}}
aria-label="Bearbeiten"
className="rounded-lg p-1.5 text-ink-400 hover:bg-ink-100 hover:text-brand-700"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setDeleting(tender);
}}
aria-label="Löschen"
className="rounded-lg p-1.5 text-ink-400 hover:bg-ink-100 hover:text-red-600"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</span>
</td>
</tr>
{expandedId === tender.id && (
<tr>
<td colSpan={8} className="border-t border-ink-200 bg-canvas p-4">
<div className="flex flex-col gap-4 lg:flex-row">
<div className="flex w-full flex-col gap-4 lg:w-1/3">
<section className="flex h-[340px] flex-col rounded-xl border border-ink-200 bg-surface p-4">
<h4 className="mb-3 flex items-center gap-2 text-sm font-bold text-ink-900">
<span className="inline-block h-5 w-1.5 rounded-full bg-brand-500" />
Experten ({tender.contacts?.length ?? 0})
</h4>
<ul className="mb-3 flex-1 space-y-2 overflow-y-auto pr-1 scrollbar-slim">
{(tender.contacts ?? []).length === 0 ? (
<li className="rounded-lg border border-dashed border-ink-200 py-10 text-center text-xs italic text-ink-400">
Keine Experten zugewiesen.
</li>
) : (
tender.contacts!.map((contact) => (
<li
key={contact.id}
className="flex items-center gap-2 rounded-lg border border-ink-200 bg-canvas px-2.5 py-2"
>
<Avatar name={contact.name} src={contact.avatar} size={32} />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold text-ink-900">
{contact.name}
</span>
<span className="block truncate text-[11px] text-ink-400">
{contact.position || '–'}
</span>
</span>
<button
type="button"
onClick={() => toggleContact(tender, contact.id)}
aria-label="Zuweisung entfernen"
className="rounded p-1 text-ink-400 hover:bg-ink-100 hover:text-red-600"
>
×
</button>
</li>
))
)}
</ul>
<button
type="button"
onClick={() => setAssigning({ tender, what: 'contacts' })}
className="w-full rounded-lg border-2 border-brand-500 py-2 text-xs font-bold text-brand-700 hover:bg-brand-500 hover:text-white"
>
Experten zuweisen
</button>
</section>
<section className="flex h-[240px] flex-col rounded-xl border border-ink-200 bg-surface p-4">
<h4 className="mb-3 flex items-center gap-2 text-sm font-bold text-ink-900">
<span className="inline-block h-5 w-1.5 rounded-full bg-sky-500" />
Kundenkontakt
</h4>
<div className="mb-3 flex flex-1 flex-col items-center justify-center rounded-lg border border-ink-200 bg-canvas p-4 text-center">
{tender.company_name ? (
<>
<Avatar
name={tender.company_name}
src={tender.company_logo}
size={48}
square
/>
<p className="mt-2 text-[10px] font-bold uppercase tracking-widest text-ink-400">
Zugeordneter Kunde
</p>
<p className="font-bold text-ink-900">{tender.company_name}</p>
</>
) : (
<p className="text-xs italic text-ink-400">Kein Kunde ausgewählt</p>
)}
</div>
<button
type="button"
onClick={() => setAssigning({ tender, what: 'company' })}
className="w-full rounded-lg bg-sky-600 py-2 text-xs font-bold text-white hover:bg-sky-700"
>
Kunden auswählen
</button>
</section>
</div>
<section className="flex w-full flex-col rounded-xl border border-ink-200 bg-surface p-5 lg:w-2/3">
<div className="mb-4 flex items-center justify-between">
<h4 className="flex items-center gap-2 text-base font-bold text-ink-900">
<span className="inline-block h-6 w-1.5 rounded-full bg-brand-500" />
Ausschreibungstext &amp; Details
</h4>
{tender.link && (
<a
href={tender.link}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-ink-200 px-3 py-1.5 text-xs font-bold text-brand-700 hover:bg-brand-50"
>
Link öffnen
</a>
)}
</div>
<TenderText
value={tender.notes ?? ''}
onChange={(value) => updateNotes(tender.id, value)}
minHeight={500}
/>
<p className="mt-2 text-xs text-ink-400">
Wird automatisch gespeichert. Dieser Text erscheint auf der ICON Website,
solange der Schalter „Online“ gesetzt ist.
</p>
</section>
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
{visible.length === 0 && (
<p className="py-12 text-center text-sm text-ink-400">
{tenders.length === 0 ? 'Noch keine Ausschreibung erfasst.' : 'Keine passt zum Filter.'}
</p>
)}
</div>
)}
{editing !== undefined && (
<TenderForm
tender={editing}
onClose={() => setEditing(undefined)}
onSaved={() => {
void load();
onChanged();
}}
/>
)}
{assigning?.what === 'contacts' && (
<Modal
open
size="md"
title="Experten auswählen"
subtitle={assigning.tender.title}
onClose={() => setAssigning(null)}
footer={<PrimaryButton onClick={() => setAssigning(null)}>Fertig</PrimaryButton>}
>
<AssignContacts
contacts={contacts}
selected={(tenders.find((t) => t.id === assigning.tender.id)?.contacts ?? []).map((c) => c.id)}
onToggle={(id) =>
toggleContact(tenders.find((t) => t.id === assigning.tender.id) ?? assigning.tender, id)
}
/>
</Modal>
)}
{assigning?.what === 'company' && (
<Modal
open
size="md"
title="Kunden zuweisen"
subtitle={assigning.tender.title}
onClose={() => setAssigning(null)}
footer={<SecondaryButton onClick={() => setAssigning(null)}>Schließen</SecondaryButton>}
>
<ul className="space-y-2">
<li>
<button
type="button"
onClick={() => {
void patch(assigning.tender, { company_id: null });
setAssigning(null);
}}
className="w-full rounded-lg border border-ink-200 px-3 py-2 text-left text-sm text-ink-500 hover:bg-ink-50"
>
– keinen Kunden zuweisen –
</button>
</li>
{companies.map((company) => (
<li key={company.id}>
<button
type="button"
onClick={() => {
void patch(
{ ...assigning.tender, company_name: company.name, company_logo: company.logo },
{ company_id: company.id, company_name: company.name, company_logo: company.logo }
);
setAssigning(null);
}}
className="flex w-full items-center gap-3 rounded-lg border border-ink-200 px-3 py-2 text-left hover:border-sky-500 hover:bg-sky-50"
>
<Avatar name={company.name} src={company.logo} size={32} square />
<span className="min-w-0">
<span className="block truncate font-semibold text-ink-900">{company.name}</span>
<span className="block truncate text-xs text-ink-400">
{company.industry || 'Branche offen'}
</span>
</span>
</button>
</li>
))}
</ul>
</Modal>
)}
<ConfirmDialog
open={deleting !== null}
title="Ausschreibung löschen?"
message={`„${deleting?.title}“ wird unwiderruflich entfernt.`}
onCancel={() => setDeleting(null)}
onConfirm={async () => {
if (!deleting) return;
try {
await tendersApi.remove(deleting.id);
toast('Ausschreibung gelöscht', 'success');
if (expandedId === deleting.id) setExpandedId(null);
void load();
onChanged();
} catch (err) {
toast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
} finally {
setDeleting(null);
}
}}
/>
</div>
);
};
/* ----------------------------------------------------------- Expertenauswahl */
const AssignContacts: React.FC<{
contacts: Contact[];
selected: number[];
onToggle: (id: number) => void;
}> = ({ contacts, selected, onToggle }) => {
const [term, setTerm] = useState('');
const visible = contacts.filter((contact) =>
`${contact.name} ${contact.position ?? ''} ${contact.company_name ?? ''}`
.toLowerCase()
.includes(term.trim().toLowerCase())
);
return (
<div className="space-y-3">
<input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder="Kontakte suchen …"
className={inputClass}
/>
<ul className="max-h-[50vh] space-y-2 overflow-y-auto scrollbar-slim">
{visible.map((contact) => {
const isSelected = selected.includes(contact.id);
return (
<li key={contact.id}>
<button
type="button"
onClick={() => onToggle(contact.id)}
className={`flex w-full items-center gap-3 rounded-lg border-2 px-3 py-2 text-left ${
isSelected ? 'border-brand-500 bg-brand-50' : 'border-ink-200 hover:bg-ink-50'
}`}
>
<Avatar name={contact.name} src={contact.avatar} size={32} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-ink-900">{contact.name}</span>
<span className="block truncate text-xs text-ink-400">
{[contact.position, contact.company_name].filter(Boolean).join(' · ') || '–'}
</span>
</span>
{isSelected && <span className="font-bold text-brand-600">✓</span>}
</button>
</li>
);
})}
{visible.length === 0 && (
<li className="py-6 text-center text-sm text-ink-400">Kein Kontakt passt zur Suche.</li>
)}
</ul>
</div>
);
};
/* ------------------------------------------------------------ Stammdatenform */
const TenderForm: React.FC<{
tender: Tender | null;
onClose: () => void;
onSaved: () => void;
}> = ({ tender, onClose, onSaved }) => {
const { toast } = useToast();
const [form, setForm] = useState({
title: tender?.title ?? '',
source: tender?.source ?? '',
link: tender?.link ?? '',
status: (tender?.status ?? 'Aktiv') as TenderStatus,
});
const [saving, setSaving] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (!form.title.trim() || !form.source.trim()) {
toast('Titel und Quelle sind erforderlich.', 'error');
return;
}
setSaving(true);
try {
if (tender) {
await tendersApi.update(tender.id, form);
toast('Ausschreibung aktualisiert', 'success');
} else {
await tendersApi.create({ ...form, stage: 'Ausschreibung', notes: '' });
toast('Neue Ausschreibung erstellt', 'success');
}
onSaved();
onClose();
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setSaving(false);
}
};
return (
<Modal
open
size="sm"
title={tender ? 'Bearbeiten' : 'Neue Ausschreibung'}
onClose={onClose}
footer={
<>
<SecondaryButton type="button" onClick={onClose}>
Abbrechen
</SecondaryButton>
<PrimaryButton type="button" onClick={submit} disabled={saving}>
{saving ? 'Speichern …' : 'Speichern'}
</PrimaryButton>
</>
}
>
<form onSubmit={submit} className="space-y-4">
<Field label="Titel *">
<input
autoFocus
value={form.title}
onChange={(event) => setForm({ ...form, title: event.target.value })}
className={inputClass}
/>
</Field>
<Field label="Quelle *">
<input
value={form.source ?? ''}
onChange={(event) => setForm({ ...form, source: event.target.value })}
className={inputClass}
/>
</Field>
<Field label="Link zur Ausschreibung">
<input
type="url"
value={form.link ?? ''}
onChange={(event) => setForm({ ...form, link: event.target.value })}
className={inputClass}
/>
</Field>
<Field label="Status">
<select
value={form.status}
onChange={(event) => setForm({ ...form, status: event.target.value as TenderStatus })}
className={inputClass}
>
{STATUS_OPTIONS.map((status) => (
<option key={status} value={status}>
{statusLabel(status)}
</option>
))}
</select>
</Field>
</form>
</Modal>
);
};
+57
View File
@@ -0,0 +1,57 @@
import React from 'react';
import { initials, userColor } from '../../types';
interface AvatarProps {
name: string;
src?: string | null;
/** Pixelgroesse der Kante. */
size?: number;
color?: string | null;
className?: string;
square?: boolean;
}
/**
* Foto, sonst Initialen auf farbigem Grund.
*
* Fuer Unternehmen wird `square` gesetzt: ein Logo in einem Kreis waere
* beschnitten, und quadratisch unterscheidet die Karte auf einen Blick von
* einer Person.
*/
export const Avatar: React.FC<AvatarProps> = ({
name,
src,
size = 40,
color,
className = '',
square = false,
}) => {
const shape = square ? 'rounded-lg' : 'rounded-full';
const style = { width: size, height: size };
if (src) {
return (
<img
src={src}
alt=""
/*
Ohne dies zieht der Browser auf einer Kanban-Kachel das Bild statt der
Kachel - die Karte bleibt dann einfach liegen.
*/
draggable={false}
style={style}
className={`${shape} shrink-0 object-cover ${square ? 'bg-surface p-0.5' : ''} ${className}`}
/>
);
}
return (
<span
style={{ ...style, backgroundColor: color || userColor({ id: name.length, color }) }}
className={`${shape} flex shrink-0 items-center justify-center font-semibold text-white ${className}`}
aria-hidden="true"
>
<span style={{ fontSize: Math.max(10, Math.round(size * 0.36)) }}>{initials(name)}</span>
</span>
);
};
+111
View File
@@ -0,0 +1,111 @@
import React, { useRef, useState } from 'react';
import { Avatar } from './Avatar';
import { compressImageFile, compressImageSource } from '../../lib/imageCompress';
import { contactsApi } from '../../lib/api';
interface AvatarPickerProps {
name: string;
value: string | null;
onChange: (value: string | null) => void;
/** Ist eine LinkedIn-Adresse gesetzt, erscheint der Knopf "Von LinkedIn". */
linkedin?: string | null;
square?: boolean;
size?: number;
}
/**
* Foto setzen: hochladen, von LinkedIn holen oder entfernen.
*
* Verkleinert wird im Browser (400 px, WebP) - das Backend bekommt nur noch
* wenige Kilobyte statt des Originals aus der Kamera.
*/
export const AvatarPicker: React.FC<AvatarPickerProps> = ({
name,
value,
onChange,
linkedin,
square = false,
size = 80,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const pick = async (file: File | undefined) => {
if (!file) return;
setBusy('Bild wird verkleinert …');
setError(null);
try {
onChange(await compressImageFile(file));
} catch (err) {
setError(err instanceof Error ? err.message : 'Das Bild konnte nicht verarbeitet werden.');
} finally {
setBusy(null);
}
};
const fromLinkedIn = async () => {
if (!linkedin) return;
setBusy('LinkedIn wird abgefragt …');
setError(null);
try {
const { avatar } = await contactsApi.linkedInPhoto(linkedin);
// Denselben Weg wie ein Upload nehmen: das Bild von LinkedIn ist gross.
onChange(await compressImageSource(avatar));
} catch (err) {
setError(err instanceof Error ? err.message : 'Das Foto konnte nicht geholt werden.');
} finally {
setBusy(null);
}
};
return (
<div className="flex items-start gap-4">
<Avatar name={name || '?'} src={value} size={size} square={square} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => inputRef.current?.click()}
className="rounded-lg border border-ink-200 px-3 py-1.5 text-sm font-medium text-ink-700 hover:bg-ink-100"
>
Bild wählen
</button>
{linkedin && (
<button
type="button"
onClick={fromLinkedIn}
className="rounded-lg border border-brand-300 bg-brand-50 px-3 py-1.5 text-sm font-medium text-brand-700 hover:bg-brand-100"
>
Von LinkedIn
</button>
)}
{value && (
<button
type="button"
onClick={() => onChange(null)}
className="rounded-lg px-3 py-1.5 text-sm font-medium text-ink-500 hover:bg-ink-100"
>
Entfernen
</button>
)}
</div>
{busy && <p className="mt-2 text-xs text-ink-400">{busy}</p>}
{error && <p className="mt-2 text-xs text-red-600">{error}</p>}
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void pick(event.target.files?.[0]);
event.target.value = '';
}}
/>
</div>
</div>
);
};
+197
View File
@@ -0,0 +1,197 @@
import React from 'react';
import { useRecorder } from '../../lib/dictation';
import { useWhisper } from '../../lib/whisper';
import { useNavigation } from '../../lib/navigation';
import { useToast } from '../Toast';
import { inputClass, labelClass } from './form';
/*
* Diktat-Knopf für Texteingaben.
*
* Die Erkennung läuft im Browser (siehe lib/whisper.tsx); hier geht es nur um
* den Knopf, die laufende Aufnahmezeit und den Weg zu den Einstellungen,
* solange das Sprachmodell noch fehlt. Ein toter Knopf wäre die schlechtere
* Antwort darauf: wer diktieren will, soll erfahren, was ihm fehlt.
*/
const MIC_ICON =
'M19 11a7 7 0 01-14 0m7 7v3m0-3a7 7 0 007-7m-7 7a7 7 0 01-7-7m7-9a3 3 0 013 3v6a3 3 0 01-6 0V5a3 3 0 013-3z';
const STOP_ICON = 'M9 9h6v6H9z';
function formatSeconds(total: number): string {
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
}
interface DictateButtonProps {
/** Bekommt den erkannten Text; der Aufrufer entscheidet, wohin damit. */
onText: (text: string) => void;
/** Wofür diktiert wird - steht im Titel des Knopfes. */
label: string;
/** Kurzform für enge Leisten, z. B. über dem WYSIWYG-Feld. */
compact?: boolean;
/**
* Wird vor dem Wechsel in die Einstellungen aufgerufen - ein offener Dialog
* muss sich sonst nicht schließen und liegt über der Ansicht.
*/
onBeforeNavigate?: () => void;
}
export const DictateButton: React.FC<DictateButtonProps> = ({
onText,
label,
compact,
onBeforeNavigate,
}) => {
const { status, isSupported } = useWhisper();
const { goToSettings } = useNavigation();
const { toast } = useToast();
const { state, seconds, start, stop, cancel } = useRecorder(onText, (message) =>
toast(message, 'error')
);
// Ohne Mikrofon oder ohne HTTPS hat ein Diktat-Knopf keinen Sinn.
if (!isSupported) return null;
if (status !== 'ready') {
return (
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
onBeforeNavigate?.();
goToSettings('diktat');
}}
title="Das Sprachmodell ist für diese Sitzung noch nicht aktiv – hier aktivieren"
className="inline-flex items-center gap-1.5 rounded border border-dashed border-ink-300 px-2 py-1 text-xs font-medium text-ink-500 hover:border-brand-300 hover:text-brand-700"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={MIC_ICON} />
</svg>
Diktat einrichten
</button>
);
}
const isRecording = state === 'recording';
const isBusy = state === 'transcribing';
return (
<span className="inline-flex items-center gap-1">
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={isRecording ? stop : () => void start()}
disabled={isBusy}
title={
isRecording
? 'Aufnahme beenden und erkennen'
: isBusy
? 'Erkennung läuft …'
: `${label} diktieren`
}
aria-label={isRecording ? 'Aufnahme beenden' : `${label} diktieren`}
className={`inline-flex items-center gap-1.5 rounded border px-2 py-1 text-xs font-medium disabled:opacity-60 ${
isRecording
? 'border-red-300 bg-red-50 text-red-700'
: 'border-ink-200 text-ink-600 hover:border-brand-300 hover:text-brand-700'
}`}
>
{isBusy ? (
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-brand-200 border-t-brand-600" />
) : (
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d={isRecording ? STOP_ICON : MIC_ICON}
/>
</svg>
)}
{isRecording ? (
<span className="tabular-nums">{formatSeconds(seconds)}</span>
) : isBusy ? (
'Erkenne …'
) : compact ? (
'Diktat'
) : (
'Diktieren'
)}
</button>
{isRecording && (
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={cancel}
title="Aufnahme verwerfen"
aria-label="Aufnahme verwerfen"
className="rounded p-1 text-ink-400 hover:bg-ink-100 hover:text-ink-700"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</span>
);
};
interface DictateTextareaProps {
value: string;
onChange: (value: string) => void;
label: string;
placeholder?: string;
rows?: number;
hint?: string;
onBeforeNavigate?: () => void;
}
/**
* Textfeld mit Diktat-Knopf in der Beschriftungszeile. Erkannter Text wird
* angehängt und ersetzt nie das Geschriebene - eine Erkennung soll nichts
* vernichten, was schon dasteht.
*/
export const DictateTextarea: React.FC<DictateTextareaProps> = ({
value,
onChange,
label,
placeholder,
rows = 4,
hint,
onBeforeNavigate,
}) => (
<div>
<div className="mb-1 flex items-center justify-between gap-2">
<label className={`${labelClass} mb-0`}>{label}</label>
<DictateButton
label={label}
compact
onBeforeNavigate={onBeforeNavigate}
onText={(text) => onChange(value.trim() ? `${value.trimEnd()} ${text}` : text)}
/>
</div>
<textarea
rows={rows}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className={`${inputClass} resize-y`}
/>
{hint && <p className="mt-1 text-xs text-ink-400">{hint}</p>}
</div>
);
/**
* Diktat für die WYSIWYG-Felder: der erkannte Text kommt als eigener Absatz
* ans Ende. Ins DOM des Editors zu schreiben wäre die Alternative - dann
* hinge das Ergebnis aber an der Schreibmarke, die während der Aufnahme
* längst woanders stehen kann.
*/
export function appendParagraph(html: string, text: string): string {
const escaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return html.trim() ? `${html}<p>${escaped}</p>` : `<p>${escaped}</p>`;
}