Files
ICON-CRM/src/components/companies/CompanyModal.tsx
T
schakal00783andClaude Opus 5.5 cbac02a96a KI: Modellauswahl als Dropdown + Abgleich mit Produktionsstand
Einstellungen -> KI: statt freiem Modellnamen ein Dropdown mit den
Chat-Modellen, die der hinterlegte OpenAI-Schluessel nutzen darf.

- Backend: listAiModels() fragt OpenAI GET /v1/models, filtert auf
  Chat-Modelle (gpt-*, o*, chatgpt-*; ohne Embedding/Audio/Bild/Instruct
  usw.), neueste zuerst, 10 min im Speicher (Schluesselwechsel laedt neu)
- Neuer Endpunkt GET /api/ai/models (admin)
- Frontend: aktives Modell bleibt waehlbar, auch wenn nicht mehr gelistet;
  Rueckfall auf Textfeld, wenn die Liste nicht abrufbar ist
- Doku: 05-ki (Modellauswahl mit Ablaufdiagramm), 10-api, 08-anleitung

Zieht ausserdem den bereits live laufenden, bisher nicht eingecheckten
Stand nach (perinco-Kontaktabgleich/MergeDialog, Projektmargen,
Whisper-Anpassungen u. a.). Repo entspricht damit icrm.icon-experts.de.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:26:30 +02:00

596 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState } from 'react';
import { formatDate, type Company, type DirectoryUser, type Tristate } from '../../types';
import { companiesApi, historyApi, type CompanyDetail } from '../../lib/api';
import { Modal, ConfirmDialog } from '../ui/Modal';
import { AvatarPicker } from '../ui/AvatarPicker';
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 { TRISTATE_LABELS } from '../contacts/contactUi';
import { useToast } from '../Toast';
import { useNavigation } from '../../lib/navigation';
import { useOptions, sortByLabel } from '../../lib/options';
import { useAuth } from '../../lib/auth';
interface CompanyModalProps {
companyId: number | null;
open: boolean;
onClose: () => void;
onSaved: () => void;
users: DirectoryUser[];
industries: string[];
groups: string[];
}
type Tab = 'stamm' | 'stammbaum' | 'historie';
const EMPTY = {
name: '',
group_name: '',
industry: '',
employees: '',
website: '',
address: '',
rating: 0,
is_customer: false,
customer_state: 'aktiv' as 'aktiv' | 'alumni',
freelancers: 'unbekannt' as Tristate,
notes: '',
owner_id: '' as number | '',
primary_contact_id: '' as number | '',
logo: null as string | null,
};
export const CompanyModal: React.FC<CompanyModalProps> = ({
companyId,
open,
onClose,
onSaved,
users,
industries,
groups,
}) => {
const { toast } = useToast();
const { open: openEntity } = useNavigation();
const { industries: industryOptions } = useOptions();
const { user } = useAuth();
const [tab, setTab] = useState<Tab>('stamm');
const [form, setForm] = useState({ ...EMPTY });
const [detail, setDetail] = useState<CompanyDetail | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const isNew = companyId === 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 ?? '' });
return;
}
let cancelled = false;
setLoading(true);
companiesApi
.detail(companyId)
.then((data) => {
if (cancelled) return;
setDetail(data);
setForm({ ...EMPTY, ...toForm(data.company) });
})
.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, companyId]);
const set = <K extends keyof typeof EMPTY>(key: K, value: (typeof EMPTY)[K]) =>
setForm((current) => ({ ...current, [key]: value }));
const save = async () => {
if (!form.name.trim()) {
toast('Der Name des Unternehmens ist erforderlich.', 'error');
return;
}
setSaving(true);
try {
const body = {
...form,
employees: form.employees === '' ? null : Number(form.employees),
owner_id: form.owner_id === '' ? null : Number(form.owner_id),
primary_contact_id: form.primary_contact_id === '' ? null : Number(form.primary_contact_id),
};
if (isNew) {
await companiesApi.create(body);
toast(`${form.name} angelegt.`, 'success');
} else {
await companiesApi.update(companyId, 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 companiesApi.remove(companyId);
toast('Unternehmen gelöscht.', 'success');
onSaved();
onClose();
} catch (err) {
toast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
} finally {
setConfirmDelete(false);
}
};
const addNote = async (text: string) => {
if (!companyId) return;
await historyApi.add('company', companyId, text);
setDetail(await companiesApi.detail(companyId));
};
const tabs: Array<{ id: Tab; label: string; count?: number }> = [
{ id: 'stamm', label: 'Stammdaten' },
{
id: 'stammbaum',
label: 'Stammbaum',
count: (detail?.contacts.length ?? 0) + (detail?.former.length ?? 0),
},
{ id: 'historie', label: 'Historie', count: detail?.history.length },
];
return (
<>
<Modal
open={open}
onClose={onClose}
size="lg"
title={isNew ? 'Neues Unternehmen' : form.name || 'Unternehmen'}
subtitle={[form.industry, form.group_name && `Gruppe ${form.group_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={form.name || 'Unternehmen'}
value={form.logo}
onChange={(value) => set('logo', value)}
square
/>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Name *">
<input
value={form.name}
onChange={(event) => set('name', event.target.value)}
className={inputClass}
/>
</Field>
<Field
label="Gruppe"
hint="Dachgesellschaft, z. B. Finanz Informatik über FI Hamburg und FI Frankfurt."
>
<input
value={form.group_name}
onChange={(event) => set('group_name', event.target.value)}
className={inputClass}
list="company-groups"
/>
<datalist id="company-groups">
{groups.map((group) => (
<option key={group} value={group} />
))}
</datalist>
</Field>
<Field
label="Branche"
hint="Gepflegt unter Einstellungen → Stammdaten."
>
<select
value={form.industry}
onChange={(event) => set('industry', event.target.value)}
className={inputClass}
>
<option value="">– keine –</option>
{/* Ein Altwert, der nicht mehr in der Liste steht, bleibt
wählbar - sonst würde er beim nächsten Speichern still
verschwinden. */}
{(industryOptions.includes(form.industry) || !form.industry
? industryOptions
: sortByLabel([form.industry, ...industryOptions])
).map((industry) => (
<option key={industry} value={industry}>
{industry}
</option>
))}
</select>
</Field>
<Field label="Mitarbeiterzahl">
<input
type="number"
min={0}
value={form.employees}
onChange={(event) => set('employees', event.target.value)}
className={inputClass}
/>
</Field>
<Field label="Website">
<input
value={form.website}
onChange={(event) => set('website', event.target.value)}
placeholder="https://…"
className={inputClass}
/>
</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>
{!isNew && (detail?.contacts.length ?? 0) > 0 && (
<Field
label="Hauptansprechpartner"
hint="Auswahl aus den Kontakten, die diesem Unternehmen zugeordnet sind."
className="sm:col-span-2"
>
<select
value={form.primary_contact_id}
onChange={(event) =>
set(
'primary_contact_id',
event.target.value === '' ? '' : Number(event.target.value)
)
}
className={inputClass}
>
<option value="">– keiner –</option>
{detail?.contacts.map((contact) => (
<option key={contact.id} value={contact.id}>
{[contact.first_name, contact.last_name].filter(Boolean).join(' ')}
{contact.position ? ` · ${contact.position}` : ''}
</option>
))}
</select>
</Field>
)}
<Field label="Adresse" className="sm:col-span-2">
<textarea
value={form.address}
onChange={(event) => set('address', event.target.value)}
rows={2}
className={inputClass}
/>
</Field>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div>
<label className={labelClass}>Bewertung</label>
<div className="flex h-[38px] items-center">
<StarRating value={form.rating} onChange={(value) => set('rating', value)} size={22} />
</div>
<p className="mt-1 text-xs text-ink-400">Wie interessant ist das Unternehmen für uns?</p>
</div>
<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>
<label className={labelClass}>Kunde</label>
<label className="flex h-[38px] items-center gap-2 text-sm text-ink-700">
<input
type="checkbox"
checked={form.is_customer}
onChange={(event) => set('is_customer', event.target.checked)}
className="h-4 w-4 accent-[var(--color-brand-600)]"
/>
Aktiver Kunde
</label>
{form.is_customer && (
<select
value={form.customer_state}
onChange={(event) =>
set('customer_state', event.target.value as 'aktiv' | 'alumni')
}
className={`${inputClass} mt-1`}
>
<option value="aktiv">aktiv</option>
<option value="alumni">Alumni</option>
</select>
)}
</div>
</div>
<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 === 'stammbaum' && detail && (
<div className="space-y-6">
<section>
<h3 className="mb-2 flex items-center gap-2 text-sm font-semibold text-ink-900">
Aktuell tätig
<Pill tone="emerald">{detail.contacts.length}</Pill>
</h3>
{detail.contacts.length === 0 ? (
<p className="text-sm text-ink-400">Kein Kontakt ist diesem Unternehmen zugeordnet.</p>
) : (
<ul className="grid gap-2 sm:grid-cols-2">
{detail.contacts.map((contact) => (
<li
key={contact.id}
className="flex items-center gap-3 rounded-lg border border-ink-200 px-3 py-2"
>
<button
type="button"
onClick={() => openEntity('contact', contact.id)}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<Avatar
name={`${contact.first_name ?? ''} ${contact.last_name}`}
src={contact.avatar}
size={32}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ink-900">
{[contact.first_name, contact.last_name].filter(Boolean).join(' ')}
{contact.id === Number(form.primary_contact_id) && (
<Pill tone="brand" className="ml-2">
Hauptkontakt
</Pill>
)}
</span>
<span className="block truncate text-xs text-ink-400">
{contact.position || contact.kind}
</span>
</span>
</button>
{/* Mail und Telefon direkt anklickbar - ein Klick öffnet
das Mailprogramm bzw. wählt die Nummer. */}
{contact.email && (
<a
href={`mailto:${contact.email}`}
title={contact.email}
className="rounded-lg p-1.5 text-ink-400 hover:bg-ink-100 hover:text-brand-700"
aria-label={`E-Mail an ${contact.last_name}`}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<path d="M3 5h18v14H3V5Zm2.2 2L12 12l6.8-5H5.2Z" />
</svg>
</a>
)}
{contact.phone && (
<a
href={`tel:${contact.phone.replace(/[^+\d]/g, '')}`}
title={contact.phone}
className="rounded-lg p-1.5 text-ink-400 hover:bg-ink-100 hover:text-brand-700"
aria-label={`${contact.last_name} anrufen`}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<path d="M6.6 3h3l1.5 4-2 1.5a12 12 0 0 0 5.4 5.4L16 12l4 1.5v3A2.5 2.5 0 0 1 17.5 19 14.5 14.5 0 0 1 5 6.5 2.5 2.5 0 0 1 6.6 3Z" />
</svg>
</a>
)}
</li>
))}
</ul>
)}
</section>
<section>
<h3 className="mb-2 flex items-center gap-2 text-sm font-semibold text-ink-900">
Verlassen
<Pill tone="amber">{detail.former.length}</Pill>
</h3>
<p className="mb-2 text-xs text-ink-400">
Wer hier einmal gearbeitet hat. Der Eintrag entsteht automatisch, sobald bei einem
Kontakt der Arbeitgeber wechselt.
</p>
{detail.former.length === 0 ? (
<p className="text-sm text-ink-400">Niemand hat das Unternehmen bisher verlassen.</p>
) : (
<ul className="divide-y divide-ink-100 rounded-lg border border-ink-200">
{detail.former.map((entry) => (
<li key={entry.employment_id}>
<button
type="button"
onClick={() => openEntity('contact', entry.id)}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left hover:bg-brand-50"
>
<Avatar
name={`${entry.first_name ?? ''} ${entry.last_name}`}
src={entry.avatar}
size={32}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ink-900">
{[entry.first_name, entry.last_name].filter(Boolean).join(' ')}
</span>
<span className="block truncate text-xs text-ink-400">
{entry.position || '–'} · bis {formatDate(entry.ended_on)}
</span>
</span>
{entry.now_company && (
<span className="shrink-0 text-xs text-ink-500">
jetzt: <span className="font-medium">{entry.now_company}</span>
</span>
)}
</button>
</li>
))}
</ul>
)}
</section>
<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 bei diesem Unternehmen.</p>
) : (
<ul className="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 text-sm">
<span className="font-medium text-ink-900">{formatDate(entry.held_on)}</span>
{entry.contact_name && <span className="ml-2 text-ink-600">{entry.contact_name}</span>}
<span className="ml-2 text-xs text-ink-400">{entry.user_name ?? ''}</span>
{entry.notes && <p className="mt-1 text-ink-600">{entry.notes}</p>}
</li>
))}
</ul>
)}
</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="Unternehmen löschen"
message={`${form.name || 'Dieses Unternehmen'} wird endgültig gelöscht. Zugeordnete Kontakte bleiben bestehen, verlieren aber ihre Zuordnung.`}
onConfirm={remove}
onCancel={() => setConfirmDelete(false)}
/>
</>
);
};
function toForm(company: Company) {
return {
name: company.name ?? '',
group_name: company.group_name ?? '',
industry: company.industry ?? '',
employees: company.employees === null || company.employees === undefined ? '' : String(company.employees),
website: company.website ?? '',
address: company.address ?? '',
rating: company.rating ?? 0,
is_customer: Boolean(company.is_customer),
customer_state: company.customer_state ?? 'aktiv',
freelancers: company.freelancers ?? 'unbekannt',
notes: company.notes ?? '',
owner_id: (company.owner_id ?? '') as number | '',
primary_contact_id: (company.primary_contact_id ?? '') as number | '',
logo: company.logo ?? null,
};
}