Quellcode des ICON CRM (5)
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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(/ /g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
return html.trim() ? `${html}<p>${escaped}</p>` : `<p>${escaped}</p>`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user