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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3f25355a6d
commit
cbac02a96a
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import type { Company, DirectoryUser } from '../../types';
|
||||
import { companiesApi } from '../../lib/api';
|
||||
import { useOptions } from '../../lib/options';
|
||||
import { useOptions, sortByLabel } from '../../lib/options';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { StarRating } from '../ui/StarRating';
|
||||
import { Pill, PrimaryButton, Segmented, inputClass } from '../ui/form';
|
||||
@@ -171,8 +171,8 @@ export const Companies: React.FC<CompaniesProps> = ({ users, onChanged, openId,
|
||||
className={`${inputClass} w-auto`}
|
||||
>
|
||||
<option value="">Alle Branchen</option>
|
||||
{/* Gepflegte Liste zuerst, dazu was im Bestand sonst noch vorkommt. */}
|
||||
{[...new Set([...industryOptions, ...industries])].map((value) => (
|
||||
{/* Gepflegte Liste, dazu was im Bestand sonst noch vorkommt - alphabetisch. */}
|
||||
{sortByLabel([...new Set([...industryOptions, ...industries])]).map((value) => (
|
||||
<option key={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { HistoryFeed } from '../history/HistoryFeed';
|
||||
import { TRISTATE_LABELS } from '../contacts/contactUi';
|
||||
import { useToast } from '../Toast';
|
||||
import { useNavigation } from '../../lib/navigation';
|
||||
import { useOptions } from '../../lib/options';
|
||||
import { useOptions, sortByLabel } from '../../lib/options';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
|
||||
interface CompanyModalProps {
|
||||
@@ -264,7 +264,7 @@ export const CompanyModal: React.FC<CompanyModalProps> = ({
|
||||
verschwinden. */}
|
||||
{(industryOptions.includes(form.industry) || !form.industry
|
||||
? industryOptions
|
||||
: [form.industry, ...industryOptions]
|
||||
: sortByLabel([form.industry, ...industryOptions])
|
||||
).map((industry) => (
|
||||
<option key={industry} value={industry}>
|
||||
{industry}
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
type ContactState,
|
||||
type DirectoryUser,
|
||||
type Employment,
|
||||
type PerincoContact,
|
||||
type Tristate,
|
||||
} from '../../types';
|
||||
import { contactsApi, getToken, type ContactDetail } from '../../lib/api';
|
||||
import { contactsApi, getToken, perincoApi, type ContactDetail } from '../../lib/api';
|
||||
import { diffContacts, hasOpenDiff, isSamePerson } from '../../lib/contactMatch';
|
||||
import { MergeDialog } from './MergeDialog';
|
||||
import { useOptions } from '../../lib/options';
|
||||
import { Modal, ConfirmDialog } from '../ui/Modal';
|
||||
import { AvatarPicker } from '../ui/AvatarPicker';
|
||||
@@ -39,6 +42,7 @@ interface ContactModalProps {
|
||||
type Tab = 'stamm' | 'werdegang' | 'historie';
|
||||
|
||||
const EMPTY = {
|
||||
title: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
position: '',
|
||||
@@ -82,6 +86,17 @@ export const ContactModal: React.FC<ContactModalProps> = ({
|
||||
const [interviewDate, setInterviewDate] = useState('');
|
||||
const [interviewNote, setInterviewNote] = useState('');
|
||||
|
||||
/*
|
||||
* Die Kontakte des perinco CRM - nur zum Nachschlagen, ob es diesen Menschen
|
||||
* drueben schon gibt. Geladen wird beim Oeffnen der Karte und nicht erst auf
|
||||
* Knopfdruck: der Hinweis nuetzt beim Anlegen nur, wenn er von selbst kommt.
|
||||
*
|
||||
* Der Server haelt die Liste eine Minute vor, das ist also kein Aufruf nach
|
||||
* drueben je Karte.
|
||||
*/
|
||||
const [remoteContacts, setRemoteContacts] = useState<PerincoContact[]>([]);
|
||||
const [mergeOpen, setMergeOpen] = useState(false);
|
||||
|
||||
const isNew = contactId === null;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -116,15 +131,95 @@ export const ContactModal: React.FC<ContactModalProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, contactId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
perincoApi
|
||||
.status()
|
||||
.then((status) => (status.configured ? perincoApi.contacts() : null))
|
||||
.then((result) => {
|
||||
if (!cancelled && result) setRemoteContacts(result.contacts);
|
||||
})
|
||||
.catch(() => {
|
||||
// Ohne Gegenseite fehlt nur der Hinweis - die Karte funktioniert.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
/*
|
||||
* Steht dieser Mensch drueben schon?
|
||||
*
|
||||
* Beim Anlegen wird gegen das gefragt, was gerade im Formular steht - sonst
|
||||
* kaeme der Hinweis erst nach dem Speichern, also zu spaet. Bei einem
|
||||
* vorhandenen Kontakt zaehlt der gespeicherte Stand.
|
||||
*/
|
||||
const remoteMatch = useMemo(() => {
|
||||
const person = isNew
|
||||
? { first_name: form.first_name, last_name: form.last_name, email: form.email }
|
||||
: detail?.contact;
|
||||
if (!person || !String(person.last_name ?? '').trim()) return null;
|
||||
return remoteContacts.find((other) => isSamePerson(person, other)) ?? null;
|
||||
}, [isNew, form.first_name, form.last_name, form.email, detail, remoteContacts]);
|
||||
|
||||
/** Nur melden, wenn es wirklich etwas abzugleichen gibt. */
|
||||
const remoteHasDiff = useMemo(() => {
|
||||
if (!remoteMatch || !detail?.contact) return false;
|
||||
return hasOpenDiff(diffContacts(detail.contact, remoteMatch));
|
||||
}, [remoteMatch, detail]);
|
||||
|
||||
const set = <K extends keyof typeof EMPTY>(key: K, value: (typeof EMPTY)[K]) =>
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
/*
|
||||
* Beim Anlegen: uebernimmt aus dem Kontakt von drueben alles, was hier noch
|
||||
* leer ist. Was schon getippt wurde, bleibt stehen - wer den Namen gerade
|
||||
* selbst geschrieben hat, will ihn nicht ueberschrieben bekommen.
|
||||
*/
|
||||
const adoptFromRemote = () => {
|
||||
if (!remoteMatch) return;
|
||||
setForm((current) => {
|
||||
const next = { ...current };
|
||||
const fill = (key: 'first_name' | 'position' | 'email' | 'phone' | 'linkedin' | 'bio' | 'lead_source', value: string | null) => {
|
||||
if (!next[key] && value) next[key] = value;
|
||||
};
|
||||
fill('first_name', remoteMatch.first_name);
|
||||
fill('position', remoteMatch.position);
|
||||
fill('email', remoteMatch.email);
|
||||
fill('phone', remoteMatch.phone);
|
||||
fill('linkedin', remoteMatch.linkedin);
|
||||
fill('bio', remoteMatch.bio);
|
||||
fill('lead_source', remoteMatch.lead_source);
|
||||
if (!next.tags && remoteMatch.tags.length > 0) next.tags = remoteMatch.tags.join(', ');
|
||||
if (!next.avatar && remoteMatch.avatar) next.avatar = remoteMatch.avatar;
|
||||
return next;
|
||||
});
|
||||
toast('Daten aus dem perinco CRM übernommen.', 'success');
|
||||
};
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
/*
|
||||
* Nach einem Abgleich: Karte UND Formular neu aufbauen. Anders als beim
|
||||
* Werdegang wurde hier an den Stammdaten selbst geschrieben - ein Formular
|
||||
* mit dem alten Stand wuerde die Aenderung beim naechsten Speichern wieder
|
||||
* zurueckdrehen.
|
||||
*/
|
||||
const reloadAfterMerge = async () => {
|
||||
if (contactId === null) return;
|
||||
const data = await contactsApi.detail(contactId);
|
||||
setDetail(data);
|
||||
setForm({ ...EMPTY, ...toForm(data.contact) });
|
||||
// Der Zwischenspeicher drueben ist zurueckgesetzt, die Liste also frisch.
|
||||
const remote = await perincoApi.contacts().catch(() => null);
|
||||
if (remote) setRemoteContacts(remote.contacts);
|
||||
};
|
||||
|
||||
const reloadDetail = async () => {
|
||||
if (contactId === null) return;
|
||||
const data = await contactsApi.detail(contactId);
|
||||
@@ -133,8 +228,8 @@ export const ContactModal: React.FC<ContactModalProps> = ({
|
||||
};
|
||||
|
||||
const fullName = useMemo(
|
||||
() => [form.first_name, form.last_name].filter(Boolean).join(' ').trim(),
|
||||
[form.first_name, form.last_name]
|
||||
() => [form.title, form.first_name, form.last_name].filter(Boolean).join(' ').trim(),
|
||||
[form.title, form.first_name, form.last_name]
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
@@ -216,7 +311,7 @@ export const ContactModal: React.FC<ContactModalProps> = ({
|
||||
title={isNew ? 'Neuer Kontakt' : fullName || 'Kontakt'}
|
||||
subtitle={
|
||||
isNew
|
||||
? 'Experten und Ansprechpartner liegen in derselben Liste.'
|
||||
? 'Experten, Ansprechpartner (Kunde) und Leads liegen in derselben Liste.'
|
||||
: [form.position, companies.find((c) => c.id === Number(form.company_id))?.name]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
@@ -269,6 +364,48 @@ export const ContactModal: React.FC<ContactModalProps> = ({
|
||||
|
||||
{(isNew || tab === 'stamm') && (
|
||||
<div className="space-y-5">
|
||||
{/* Derselbe Mensch steht drüben schon - je nachdem, ob hier
|
||||
gerade angelegt oder gepflegt wird, ist das ein Angebot zum
|
||||
Übernehmen oder eines zum Abgleichen. */}
|
||||
{remoteMatch && isNew && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3">
|
||||
<p className="min-w-0 flex-1 text-sm text-emerald-900">
|
||||
<span className="font-semibold">{remoteMatch.name}</span> steht schon im
|
||||
perinco CRM
|
||||
{remoteMatch.company_name ? ` (${remoteMatch.company_name})` : ''}. Die dort
|
||||
gepflegten Daten lassen sich übernehmen – was hier schon getippt ist, bleibt
|
||||
stehen.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={adoptFromRemote}
|
||||
className="rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
Daten übernehmen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remoteMatch && !isNew && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3">
|
||||
<p className="min-w-0 flex-1 text-sm text-emerald-900">
|
||||
Dieser Kontakt wird auch im perinco CRM geführt.
|
||||
{remoteHasDiff
|
||||
? ' Es gibt Unterschiede – der Abgleich schreibt die Auswahl auf beiden Seiten.'
|
||||
: ' Beide Seiten stimmen überein.'}
|
||||
</p>
|
||||
{remoteHasDiff && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMergeOpen(true)}
|
||||
className="rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
Unterschiede abgleichen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AvatarPicker
|
||||
name={fullName || 'Kontakt'}
|
||||
value={form.avatar}
|
||||
@@ -277,6 +414,13 @@ export const ContactModal: React.FC<ContactModalProps> = ({
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Titel" hint={'Freitext, z. B. „Dr.“ oder „Prof. Dr.“'}>
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={(event) => set('title', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Vorname">
|
||||
<input
|
||||
value={form.first_name}
|
||||
@@ -585,6 +729,21 @@ export const ContactModal: React.FC<ContactModalProps> = ({
|
||||
onConfirm={remove}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
|
||||
{mergeOpen && remoteMatch && detail?.contact && (
|
||||
<MergeDialog
|
||||
open={mergeOpen}
|
||||
contact={detail.contact}
|
||||
remote={remoteMatch}
|
||||
onClose={() => setMergeOpen(false)}
|
||||
onMerged={() => {
|
||||
// Der Abgleich hat direkt in der Datenbank geschrieben - Karte und
|
||||
// Liste zeigen sonst den Stand von vorher.
|
||||
void reloadAfterMerge();
|
||||
onSaved();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -592,6 +751,7 @@ export const ContactModal: React.FC<ContactModalProps> = ({
|
||||
function toForm(contact: Partial<Contact> | undefined) {
|
||||
if (!contact) return {};
|
||||
return {
|
||||
title: contact.title ?? '',
|
||||
first_name: contact.first_name ?? '',
|
||||
last_name: contact.last_name ?? '',
|
||||
position: contact.position ?? '',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../../types';
|
||||
import { contactsApi, perincoApi } from '../../lib/api';
|
||||
import { useOptions } from '../../lib/options';
|
||||
import { buildMatchIndex } from '../../lib/contactMatch';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { Pill, PrimaryButton, Segmented, inputClass } from '../ui/form';
|
||||
import { ContactModal } from './ContactModal';
|
||||
@@ -174,9 +175,22 @@ export const Contacts: React.FC<ContactsProps> = ({
|
||||
};
|
||||
}, [perincoOn]);
|
||||
|
||||
/*
|
||||
* Wer steht in beiden Haeusern?
|
||||
*
|
||||
* Gunther Stammwitz ist derselbe Mensch, ob ihn nun das ICON CRM oder das
|
||||
* perinco CRM fuehrt. Frueher stand er deshalb zweimal in der Liste - einmal
|
||||
* als eigener Kontakt, einmal gruen umrandet von drueben. Jetzt faellt der
|
||||
* Eintrag von drueben weg und der eigene traegt den Hinweis.
|
||||
*/
|
||||
const matches = useMemo(() => buildMatchIndex(contacts, perinco), [contacts, perinco]);
|
||||
|
||||
const visiblePerinco = useMemo(() => {
|
||||
const q = term.trim().toLowerCase();
|
||||
return perinco.filter((contact) => {
|
||||
// Doppelt gefuehrte Kontakte zeigt die eigene Karte - hier waere es der
|
||||
// zweite Eintrag zur selben Person.
|
||||
if (matches.byRemote.has(contact.id)) return false;
|
||||
// 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;
|
||||
@@ -188,7 +202,7 @@ export const Contacts: React.FC<ContactsProps> = ({
|
||||
.toLowerCase()
|
||||
.includes(q);
|
||||
});
|
||||
}, [perinco, term, kind, status]);
|
||||
}, [perinco, term, kind, status, matches]);
|
||||
|
||||
// Gefiltert wird im Browser: die Liste ist klein genug, und so bleibt das
|
||||
// Tippen ohne Wartezeit.
|
||||
@@ -357,6 +371,7 @@ export const Contacts: React.FC<ContactsProps> = ({
|
||||
<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>
|
||||
{matches.byLocal.has(contact.id) && <Pill tone="emerald">auch in perinco</Pill>}
|
||||
{contact.lead_source && <Pill>{contact.lead_source}</Pill>}
|
||||
{(contact.interview_count ?? 0) > 0 && (
|
||||
<Pill tone="violet">{contact.interview_count} Interview(s)</Pill>
|
||||
@@ -450,6 +465,12 @@ export const Contacts: React.FC<ContactsProps> = ({
|
||||
<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>
|
||||
{matches.byLocal.has(contact.id) && (
|
||||
<span
|
||||
title="Dieser Kontakt steht auch im perinco CRM"
|
||||
className="h-2 w-2 shrink-0 rounded-full bg-emerald-500"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-600">{contact.position || '–'}</td>
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { Contact, PerincoContact } from '../../types';
|
||||
import { perincoApi } from '../../lib/api';
|
||||
import { diffContacts, type FieldDiff, type MergeKey } from '../../lib/contactMatch';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { PrimaryButton, SecondaryButton } from '../ui/form';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useToast } from '../Toast';
|
||||
|
||||
/*
|
||||
* Abgleich eines Kontakts, den beide Häuser führen.
|
||||
*
|
||||
* Er ist Feld für Feld aufgebaut und nicht als "diese Seite gewinnt": in aller
|
||||
* Regel ist die eine Seite bei der Telefonnummer aktueller und die andere beim
|
||||
* Foto. Eine Seite pauschal zu übernehmen hieße, die Pflegearbeit der anderen
|
||||
* wegzuwerfen.
|
||||
*
|
||||
* Vorbelegt wird nur das Unstrittige: was auf einer Seite fehlt und auf der
|
||||
* anderen steht, wird übernommen - danach war die Frage. Wo sich zwei gepflegte
|
||||
* Werte widersprechen, bleibt die Entscheidung offen und der Vorgabewert ist
|
||||
* "so lassen". Eine Vorauswahl wäre dort geraten, und bei fremden Daten wird
|
||||
* nicht geraten.
|
||||
*/
|
||||
|
||||
interface MergeDialogProps {
|
||||
open: boolean;
|
||||
contact: Contact;
|
||||
remote: PerincoContact;
|
||||
onClose: () => void;
|
||||
/** Nach erfolgreichem Abgleich - die Karte lädt sich dann neu. */
|
||||
onMerged: () => void;
|
||||
}
|
||||
|
||||
/** Wessen Wert gilt. 'skip' = so lassen, 'both' = nur bei Schlagworten. */
|
||||
type Choice = 'local' | 'remote' | 'skip' | 'both';
|
||||
|
||||
const defaultChoice = (entry: FieldDiff): Choice => {
|
||||
if (entry.state === 'only-remote') return 'remote';
|
||||
if (entry.state === 'only-local') return 'local';
|
||||
return 'skip';
|
||||
};
|
||||
|
||||
const show = (value: string | string[] | null): string => {
|
||||
if (Array.isArray(value)) return value.join(', ');
|
||||
return String(value ?? '').trim();
|
||||
};
|
||||
|
||||
export const MergeDialog: React.FC<MergeDialogProps> = ({
|
||||
open,
|
||||
contact,
|
||||
remote,
|
||||
onClose,
|
||||
onMerged,
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const diffs = useMemo(() => diffContacts(contact, remote), [contact, remote]);
|
||||
const openDiffs = useMemo(() => diffs.filter((entry) => entry.state !== 'equal'), [diffs]);
|
||||
|
||||
const [choices, setChoices] = useState<Record<string, Choice>>(() =>
|
||||
Object.fromEntries(diffs.map((entry) => [entry.field.key, defaultChoice(entry)]))
|
||||
);
|
||||
|
||||
const pick = (key: MergeKey, choice: Choice) =>
|
||||
setChoices((current) => ({ ...current, [key]: choice }));
|
||||
|
||||
/*
|
||||
* Aus den Entscheidungen werden zwei Päckchen: was hier geschrieben wird und
|
||||
* was drüben. Geschrieben wird immer die andere Seite - wer "ICON gilt" sagt,
|
||||
* ändert drüben, nicht hier.
|
||||
*/
|
||||
const { localPatch, remotePatch, touched } = useMemo(() => {
|
||||
const local: Record<string, unknown> = {};
|
||||
const there: Record<string, unknown> = {};
|
||||
let count = 0;
|
||||
|
||||
for (const entry of diffs) {
|
||||
if (entry.state === 'equal') continue;
|
||||
const choice = choices[entry.field.key] ?? 'skip';
|
||||
if (choice === 'skip') continue;
|
||||
count += 1;
|
||||
|
||||
if (choice === 'both' && entry.field.kind === 'tags') {
|
||||
// Schlagworte sind das eine Feld, bei dem beide Seiten recht haben
|
||||
// können - zwei Listen schließen einander nicht aus.
|
||||
const union = [
|
||||
...new Set([
|
||||
...((entry.local as string[] | null) ?? []),
|
||||
...((entry.remote as string[] | null) ?? []),
|
||||
]),
|
||||
];
|
||||
local[entry.field.bridge] = union;
|
||||
there[entry.field.bridge] = union;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (choice === 'local') there[entry.field.bridge] = entry.local;
|
||||
else local[entry.field.bridge] = entry.remote;
|
||||
}
|
||||
|
||||
return { localPatch: local, remotePatch: there, touched: count };
|
||||
}, [diffs, choices]);
|
||||
|
||||
const apply = async () => {
|
||||
if (touched === 0) {
|
||||
toast('Es ist nichts ausgewählt.', 'info');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await perincoApi.merge(remote.id, {
|
||||
contact_id: contact.id,
|
||||
local: localPatch,
|
||||
remote: remotePatch,
|
||||
});
|
||||
toast(
|
||||
`Abgeglichen – ${result.local_changed.length} Feld(er) hier, ${result.remote_changed.length} im perinco CRM.`,
|
||||
'success'
|
||||
);
|
||||
onMerged();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Abgleich fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
title="Mit dem perinco CRM abgleichen"
|
||||
subtitle={`${contact.name} steht in beiden Systemen. Die Auswahl wird auf beiden Seiten geschrieben.`}
|
||||
footer={
|
||||
<>
|
||||
<span className="mr-auto text-sm text-ink-400">
|
||||
{touched === 0 ? 'Nichts ausgewählt' : `${touched} Feld(er) werden geschrieben`}
|
||||
</span>
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
Abbrechen
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="button" onClick={apply} disabled={saving || touched === 0}>
|
||||
{saving ? 'Wird abgeglichen …' : 'Abgleichen'}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{openDiffs.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-ink-400">
|
||||
Beide Seiten sind identisch – es gibt nichts abzugleichen.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{openDiffs.map((entry) => {
|
||||
const choice = choices[entry.field.key] ?? 'skip';
|
||||
const isImage = entry.field.kind === 'image';
|
||||
|
||||
return (
|
||||
<li key={entry.field.key} className="rounded-xl border border-ink-200 bg-surface p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-ink-900">{entry.field.label}</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
entry.state === 'conflict'
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: 'bg-ink-100 text-ink-600'
|
||||
}`}
|
||||
>
|
||||
{entry.state === 'conflict'
|
||||
? 'unterschiedlich'
|
||||
: entry.state === 'only-local'
|
||||
? 'fehlt im perinco CRM'
|
||||
: 'fehlt hier'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<SideButton
|
||||
active={choice === 'local'}
|
||||
label="ICON"
|
||||
tone="brand"
|
||||
onClick={() => pick(entry.field.key, 'local')}
|
||||
>
|
||||
{isImage ? (
|
||||
<Avatar name={contact.name} src={entry.local as string | null} size={40} />
|
||||
) : (
|
||||
show(entry.local) || <span className="text-ink-400">– leer –</span>
|
||||
)}
|
||||
</SideButton>
|
||||
|
||||
<SideButton
|
||||
active={choice === 'remote'}
|
||||
label="perinco"
|
||||
tone="emerald"
|
||||
onClick={() => pick(entry.field.key, 'remote')}
|
||||
>
|
||||
{isImage ? (
|
||||
<Avatar name={remote.name} src={entry.remote as string | null} size={40} />
|
||||
) : (
|
||||
show(entry.remote) || <span className="text-ink-400">– leer –</span>
|
||||
)}
|
||||
</SideButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<ChoiceChip
|
||||
active={choice === 'skip'}
|
||||
onClick={() => pick(entry.field.key, 'skip')}
|
||||
>
|
||||
so lassen
|
||||
</ChoiceChip>
|
||||
{entry.field.kind === 'tags' && (
|
||||
<ChoiceChip
|
||||
active={choice === 'both'}
|
||||
onClick={() => pick(entry.field.key, 'both')}
|
||||
>
|
||||
beide zusammenführen
|
||||
</ChoiceChip>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const SideButton: React.FC<{
|
||||
active: boolean;
|
||||
label: string;
|
||||
tone: 'brand' | 'emerald';
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}> = ({ active, label, tone, onClick, children }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-lg border-2 p-3 text-left text-sm transition-colors ${
|
||||
active
|
||||
? tone === 'brand'
|
||||
? 'border-brand-500 bg-brand-50'
|
||||
: 'border-emerald-500 bg-emerald-50'
|
||||
: 'border-ink-200 hover:border-ink-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`mb-1 block text-xs font-semibold uppercase tracking-wide ${
|
||||
tone === 'brand' ? 'text-brand-700' : 'text-emerald-700'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="block break-words text-ink-800">{children}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const ChoiceChip: React.FC<{
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}> = ({ active, onClick, children }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||
active ? 'bg-ink-800 text-white' : 'border border-ink-200 text-ink-600 hover:border-ink-300'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
@@ -10,6 +10,9 @@ export const STATUS_TONE: Record<ContactStatus, string> = {
|
||||
|
||||
export const KIND_TONE: Record<ContactKind, string> = {
|
||||
Experte: 'brand',
|
||||
'Ansprechpartner (Kunde)': 'violet',
|
||||
// Altbestand, der die Umbenennung nicht mitgemacht hat - sonst bliebe die
|
||||
// Kachel farblos.
|
||||
Ansprechpartner: 'violet',
|
||||
Lead: 'sky',
|
||||
};
|
||||
|
||||
@@ -8,6 +8,15 @@ import { RichTextEditor } from '../ui/RichTextEditor';
|
||||
import { DictateButton, appendParagraph } from '../ui/Dictate';
|
||||
import { toEditorHtml } from '../../lib/richText';
|
||||
import { useToast } from '../Toast';
|
||||
import {
|
||||
BASIS_LABEL,
|
||||
HOURS_PER_DAY,
|
||||
formatEuro,
|
||||
formatMarginAmount,
|
||||
formatMarginPercent,
|
||||
marginTone,
|
||||
projectMargin,
|
||||
} from './margin';
|
||||
|
||||
interface ProjectsProps {
|
||||
companies: Company[];
|
||||
@@ -126,10 +135,13 @@ export const Projects: React.FC<ProjectsProps> = ({ companies, onChanged, openId
|
||||
{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>
|
||||
<div className="mt-auto space-y-1 pt-1">
|
||||
<p className="text-xs text-ink-400">
|
||||
{formatDate(project.started_on)} – {project.ended_on ? formatDate(project.ended_on) : 'offen'}
|
||||
{project.rate ? ` · ${project.rate.toLocaleString('de-DE')} €/Tag` : ''}
|
||||
</p>
|
||||
<MarginLine project={project} />
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -152,6 +164,85 @@ export const Projects: React.FC<ProjectsProps> = ({ companies, onChanged, openId
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* Eine Zeile Marge unter der Kachel. Fehlt ein Satz, steht dort der Grund -
|
||||
* eine leere Zeile liesse offen, ob die Marge null ist oder unbekannt.
|
||||
*/
|
||||
const MarginLine: React.FC<{ project: Project }> = ({ project }) => {
|
||||
const margin = projectMargin(project);
|
||||
if (!margin) {
|
||||
const reason = !project.rate
|
||||
? 'Marge: kein Tagessatz'
|
||||
: !project.contact_id
|
||||
? 'Marge: kein Berater'
|
||||
: 'Marge: kein Stundensatz beim Berater';
|
||||
return <p className="text-xs text-ink-300">{reason}</p>;
|
||||
}
|
||||
return (
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<Pill tone={marginTone(margin.percent)}>
|
||||
Marge {formatMarginAmount(margin.amount)} · {formatMarginPercent(margin.percent)}
|
||||
</Pill>
|
||||
<span className="text-ink-400">
|
||||
Kosten {formatEuro(margin.cost)}/Tag ({BASIS_LABEL[margin.basis]})
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
/* Die Aufschluesselung in der Projektkarte - rechnet mit den Werten im Formular. */
|
||||
const MarginPanel: React.FC<{ rate: string; contact: Contact | null }> = ({ rate, contact }) => {
|
||||
const margin = projectMargin({
|
||||
rate: rate === '' ? null : Number(rate),
|
||||
contact_rate_onsite: contact?.rate_onsite ?? null,
|
||||
contact_rate_remote: contact?.rate_remote ?? null,
|
||||
});
|
||||
|
||||
if (!margin) {
|
||||
const reason =
|
||||
rate === ''
|
||||
? 'Sobald ein Tagessatz eingetragen ist und der Berater einen Stundensatz hat, steht hier die Marge.'
|
||||
: !contact
|
||||
? 'Ohne Berater lässt sich keine Marge berechnen.'
|
||||
: `Für ${contact.name} ist kein Stundensatz hinterlegt.`;
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-ink-200 bg-ink-50/50 p-4">
|
||||
<p className={labelClass}>Marge</p>
|
||||
<p className="text-sm text-ink-400">{reason}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-ink-200 bg-ink-50/50 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className={labelClass}>Marge je Tag</p>
|
||||
<Pill tone={marginTone(margin.percent)}>
|
||||
{formatMarginAmount(margin.amount)} · {formatMarginPercent(margin.percent)}
|
||||
</Pill>
|
||||
</div>
|
||||
<dl className="mt-2 grid gap-x-4 gap-y-1 text-sm sm:grid-cols-2">
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="text-ink-500">Tagessatz Kunde</dt>
|
||||
<dd className="font-medium text-ink-900">{formatEuro(margin.revenue)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="text-ink-500">Stundensatz Berater ({BASIS_LABEL[margin.basis]})</dt>
|
||||
<dd className="font-medium text-ink-900">{formatEuro(margin.hourly)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="text-ink-500">Kosten ({HOURS_PER_DAY} h)</dt>
|
||||
<dd className="font-medium text-ink-900">{formatEuro(margin.cost)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="text-ink-500">Marge</dt>
|
||||
<dd className="font-medium text-ink-900">{formatMarginAmount(margin.amount)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EMPTY = {
|
||||
title: '',
|
||||
company_id: '' as number | '',
|
||||
@@ -189,6 +280,12 @@ const ProjectModal: React.FC<{
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
// Der Berater darf im Formular gewechselt werden - die Marge folgt sofort.
|
||||
const selectedContact = useMemo(
|
||||
() => contacts.find((item) => item.id === form.contact_id) ?? null,
|
||||
[contacts, form.contact_id]
|
||||
);
|
||||
|
||||
const set = <K extends keyof typeof EMPTY>(key: K, value: (typeof EMPTY)[K]) =>
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
@@ -328,7 +425,7 @@ const ProjectModal: React.FC<{
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Tagessatz">
|
||||
<Field label="Tagessatz (€/Tag)" hint={`Was ICON dem Kunden berechnet - ${HOURS_PER_DAY} h je Tag.`}>
|
||||
<input
|
||||
type="number"
|
||||
value={form.rate}
|
||||
@@ -338,6 +435,8 @@ const ProjectModal: React.FC<{
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<MarginPanel rate={form.rate} contact={selectedContact} />
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Notizen</label>
|
||||
{/*
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Marge eines laufenden Projekts.
|
||||
*
|
||||
* Am Projekt steht der Tagessatz, den ICON dem Kunden berechnet. Beim Berater
|
||||
* stehen Stundensaetze. Ein Tag sind 8 Stunden - daraus werden die Kosten.
|
||||
*
|
||||
* Es gibt zwei Saetze (vor Ort / remote) und am Projekt kein Feld, welcher
|
||||
* gilt. Vorrang hat der Remote-Satz; nur wenn er fehlt, wird der Vor-Ort-Satz
|
||||
* genommen. Welcher es war, steht im Ergebnis, damit die Oberflaeche es
|
||||
* ausweisen kann.
|
||||
*/
|
||||
|
||||
export const HOURS_PER_DAY = 8;
|
||||
|
||||
export interface Margin {
|
||||
/** Tagessatz an den Kunden. */
|
||||
revenue: number;
|
||||
/** Stundensatz des Beraters. */
|
||||
hourly: number;
|
||||
/** Welcher Satz benutzt wurde. */
|
||||
basis: 'remote' | 'onsite';
|
||||
/** Tagessatz des Beraters (Stundensatz x 8). */
|
||||
cost: number;
|
||||
/** Tagessatz minus Kosten. */
|
||||
amount: number;
|
||||
/** Marge in Prozent vom Tagessatz. */
|
||||
percent: number;
|
||||
}
|
||||
|
||||
interface MarginInput {
|
||||
rate: number | null | undefined;
|
||||
contact_rate_onsite?: number | null;
|
||||
contact_rate_remote?: number | null;
|
||||
}
|
||||
|
||||
/** Gibt `null` zurueck, solange Tagessatz oder Stundensatz fehlt. */
|
||||
export function projectMargin(project: MarginInput): Margin | null {
|
||||
const revenue = project.rate;
|
||||
if (revenue === null || revenue === undefined || !Number.isFinite(revenue)) return null;
|
||||
|
||||
const remote = project.contact_rate_remote;
|
||||
const onsite = project.contact_rate_onsite;
|
||||
const hourly = remote ?? onsite;
|
||||
if (hourly === null || hourly === undefined || !Number.isFinite(hourly)) return null;
|
||||
|
||||
const cost = hourly * HOURS_PER_DAY;
|
||||
const amount = revenue - cost;
|
||||
return {
|
||||
revenue,
|
||||
hourly,
|
||||
basis: remote === null || remote === undefined ? 'onsite' : 'remote',
|
||||
cost,
|
||||
amount,
|
||||
percent: revenue === 0 ? 0 : (amount / revenue) * 100,
|
||||
};
|
||||
}
|
||||
|
||||
const EURO = (value: number) =>
|
||||
value.toLocaleString('de-DE', { maximumFractionDigits: 2, minimumFractionDigits: 0 });
|
||||
|
||||
/** "+ 320 €" bzw. "− 80 €". */
|
||||
export function formatMarginAmount(amount: number): string {
|
||||
const sign = amount < 0 ? '\u2212' : '+';
|
||||
return `${sign} ${EURO(Math.abs(amount))} €`;
|
||||
}
|
||||
|
||||
/** "40,0 %". */
|
||||
export function formatMarginPercent(percent: number): string {
|
||||
return `${percent.toLocaleString('de-DE', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} %`;
|
||||
}
|
||||
|
||||
export function formatEuro(value: number): string {
|
||||
return `${EURO(value)} €`;
|
||||
}
|
||||
|
||||
export const BASIS_LABEL: Record<Margin['basis'], string> = {
|
||||
remote: 'remote',
|
||||
onsite: 'vor Ort',
|
||||
};
|
||||
|
||||
/** Grün ab 25 %, gelb ab 0, rot darunter - dieselbe Schwelle in Liste und Karte. */
|
||||
export function marginTone(percent: number): string {
|
||||
if (percent >= 25) return 'emerald';
|
||||
if (percent >= 0) return 'amber';
|
||||
return 'red';
|
||||
}
|
||||
@@ -29,12 +29,27 @@ export const AiSettings: React.FC = () => {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [askRemove, setAskRemove] = useState(false);
|
||||
/** null = noch nicht geladen; Fehlertext, wenn OpenAI die Liste nicht liefert. */
|
||||
const [models, setModels] = useState<string[] | null>(null);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
|
||||
const loadModels = async () => {
|
||||
setModels(null);
|
||||
setModelsError(null);
|
||||
try {
|
||||
const data = await aiApi.models();
|
||||
setModels(data.models.map((m) => m.id));
|
||||
} catch (err) {
|
||||
setModelsError(err instanceof Error ? err.message : 'Modellliste nicht abrufbar.');
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const data = await aiApi.config();
|
||||
setConfig(data);
|
||||
setModel(data.model);
|
||||
if (data.configured) void loadModels();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
|
||||
}
|
||||
@@ -53,6 +68,11 @@ export const AiSettings: React.FC = () => {
|
||||
setConfig(data);
|
||||
setModel(data.model);
|
||||
setKey('');
|
||||
// Ein anderer Schlüssel darf andere Modelle nutzen.
|
||||
if (changes.key !== undefined) {
|
||||
if (data.configured) void loadModels();
|
||||
else setModels(null);
|
||||
}
|
||||
toast('Gespeichert.', 'success');
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
@@ -150,17 +170,48 @@ export const AiSettings: React.FC = () => {
|
||||
<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.`}
|
||||
label="Modell"
|
||||
hint={
|
||||
models
|
||||
? `${models.length} Modelle für diesen Schlüssel verfügbar. Vorgabe ist ${config.defaultModel}.`
|
||||
: `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`}
|
||||
/>
|
||||
{models ? (
|
||||
<select
|
||||
value={model}
|
||||
onChange={(event) => setModel(event.target.value)}
|
||||
className={`${inputClass} font-mono`}
|
||||
>
|
||||
{/* Das aktive Modell bleibt wählbar, auch wenn OpenAI es nicht (mehr) listet. */}
|
||||
{Array.from(new Set([config.model, config.defaultModel, ...models]))
|
||||
.filter((id) => models.includes(id) || id === config.model)
|
||||
.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
{id === config.defaultModel ? ' (Vorgabe)' : ''}
|
||||
{!models.includes(id) ? ' – nicht verfügbar' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={model}
|
||||
onChange={(event) => setModel(event.target.value)}
|
||||
placeholder={config.configured && !modelsError ? 'Modelle werden geladen …' : config.defaultModel}
|
||||
spellCheck={false}
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{modelsError && (
|
||||
<p className="mt-2 text-xs text-red-600">
|
||||
Modellliste nicht abrufbar ({modelsError}) – Name bitte von Hand eintragen.{' '}
|
||||
<button type="button" className="font-medium underline" onClick={() => void loadModels()}>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<PrimaryButton
|
||||
|
||||
@@ -12,8 +12,8 @@ import { fetchModelSize, formatSize } from '../../lib/whisperModels';
|
||||
*
|
||||
* 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.
|
||||
* gebraucht wird - einmal herunterladen, danach laeuft es. Und der Weg, es
|
||||
* wieder loszuwerden.
|
||||
*/
|
||||
|
||||
export const WhisperSettings: React.FC = () => {
|
||||
@@ -79,10 +79,9 @@ export const WhisperSettings: React.FC = () => {
|
||||
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.
|
||||
Einmal herunterladen genügt: das Modell bleibt in diesem Browser und steht danach bei
|
||||
jeder Anmeldung von selbst bereit. Auf einem anderen Gerät oder in einem anderen Browser
|
||||
fällt der Download erneut an.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -109,7 +108,7 @@ export const WhisperSettings: React.FC = () => {
|
||||
? 'Gleich fertig.'
|
||||
: progressLabel || 'Download startet …'
|
||||
: isCached
|
||||
? 'Das Modell liegt in diesem Browser – ein Klick aktiviert es für diese Sitzung.'
|
||||
? 'Das Modell liegt in diesem Browser und wird gerade bereitgestellt.'
|
||||
: `Einmalig werden ${sizeLabel ?? 'die Modelldateien'} geladen – danach bleibt es in diesem Browser.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user