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>
596 lines
25 KiB
TypeScript
596 lines
25 KiB
TypeScript
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,
|
||
};
|
||
}
|