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
-1
@@ -1,6 +1,6 @@
|
|||||||
14.09.2026
|
14.09.2026
|
||||||
|
|
||||||
Was muss geändert werden:
|
Was muss geändert werden am ICON CRM:
|
||||||
|
|
||||||
- Verfügbarkeitsstatus entfernen aus den Kontakten
|
- Verfügbarkeitsstatus entfernen aus den Kontakten
|
||||||
- Können wir eine Liste automatisiert erstellen, der letzte Arbeitgeber (Branche & Unternehmen)
|
- Können wir eine Liste automatisiert erstellen, der letzte Arbeitgeber (Branche & Unternehmen)
|
||||||
|
|||||||
@@ -152,6 +152,24 @@ Projekt).
|
|||||||
(Aktiv / Nicht aktiv / Abgeschlossen), `rate`, `notes` (HTML). Im Menü heißt die
|
(Aktiv / Nicht aktiv / Abgeschlossen), `rate`, `notes` (HTML). Im Menü heißt die
|
||||||
Ansicht **Laufende Projekte**.
|
Ansicht **Laufende Projekte**.
|
||||||
|
|
||||||
|
`rate` ist ein **Tagessatz** in Euro – das, was ICON dem Kunden berechnet. Beim
|
||||||
|
Berater stehen dagegen **Stundensätze** (`rate_onsite`, `rate_remote` in
|
||||||
|
`icon_contacts`). Die Marge rechnet mit **1 Tag = 8 Stunden**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Marge = rate − Stundensatz des Beraters × 8
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Marge wird **nicht gespeichert**. Sie ist eine Rechnung aus zwei Feldern,
|
||||||
|
die sich beide ändern können – ein gespeicherter Wert wäre nach der ersten
|
||||||
|
Satzänderung falsch, ohne dass es jemand merkt. `GET /api/projects` liefert die
|
||||||
|
beiden Sätze des Beraters deshalb als `contact_rate_onsite` /
|
||||||
|
`contact_rate_remote` mit; gerechnet wird in `src/components/projects/margin.ts`.
|
||||||
|
|
||||||
|
Es gibt am Projekt kein Feld für die Einsatzart. **Vorrang hat der
|
||||||
|
Remote-Satz**; nur wenn er fehlt, wird der Vor-Ort-Satz genommen. Welcher es
|
||||||
|
war, steht in der Oberfläche dabei.
|
||||||
|
|
||||||
Im übernommenen Bestand steht im Kopf von `notes` ein Textblock aus dem alten
|
Im übernommenen Bestand steht im Kopf von `notes` ein Textblock aus dem alten
|
||||||
CRM (`KUNDE:`, `KONTAKT:`, `EXPERTE:`, `START:`, `ENDE:`), darunter der
|
CRM (`KUNDE:`, `KONTAKT:`, `EXPERTE:`, `START:`, `ENDE:`), darunter der
|
||||||
übernommene Ausschreibungstext als HTML. `src/lib/richText.ts` macht daraus im
|
übernommene Ausschreibungstext als HTML. `src/lib/richText.ts` macht daraus im
|
||||||
|
|||||||
+7
-1
@@ -38,7 +38,7 @@ flowchart TD
|
|||||||
V --> KON["contacts/<br/>Liste, Kacheln, Netzwerk, CV-Import"]
|
V --> KON["contacts/<br/>Liste, Kacheln, Netzwerk, CV-Import"]
|
||||||
V --> UNT["companies/<br/>Liste, Kacheln, Group, Netzwerk"]
|
V --> UNT["companies/<br/>Liste, Kacheln, Group, Netzwerk"]
|
||||||
V --> AUS["tenders/<br/>Tabelle, Live!, Archiv"]
|
V --> AUS["tenders/<br/>Tabelle, Live!, Archiv"]
|
||||||
V --> PRJ["projects/"]
|
V --> PRJ["projects/<br/>Kacheln, Karte, Marge"]
|
||||||
V --> HIS["history/"]
|
V --> HIS["history/"]
|
||||||
V --> SET["settings/<br/>Nutzer, Stammdaten, Konto, Diktat"]
|
V --> SET["settings/<br/>Nutzer, Stammdaten, Konto, Diktat"]
|
||||||
KON --> NW["network/Network.tsx<br/>d3-force + SVG"]
|
KON --> NW["network/Network.tsx<br/>d3-force + SVG"]
|
||||||
@@ -46,6 +46,12 @@ flowchart TD
|
|||||||
NW --> CT["network/CompanyTree.tsx"]
|
NW --> CT["network/CompanyTree.tsx"]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`projects/margin.ts` rechnet die Marge: Tagessatz des Projekts minus
|
||||||
|
Stundensatz des Beraters mal acht. Die Rechnung liegt neben der Ansicht und
|
||||||
|
nicht im Backend, weil sie in der Projektkarte **live** mitlaufen muss – dort
|
||||||
|
lassen sich Tagessatz und Berater ändern, bevor gespeichert wird. Siehe
|
||||||
|
`docs/02-datenmodell.md`, Abschnitt `icon_projects`.
|
||||||
|
|
||||||
Zwei Dinge liegen bewusst in `App.tsx`:
|
Zwei Dinge liegen bewusst in `App.tsx`:
|
||||||
|
|
||||||
* Die Listen der Unternehmen und Benutzer werden **einmal** geladen und
|
* Die Listen der Unternehmen und Benutzer werden **einmal** geladen und
|
||||||
|
|||||||
+34
-1
@@ -22,12 +22,45 @@ Dienst neu starten müssen.
|
|||||||
| Anzeige | nur verkürzt (`sk-proj…a1b2`) – vollständig verlässt der Schlüssel den Server nie |
|
| Anzeige | nur verkürzt (`sk-proj…a1b2`) – vollständig verlässt der Schlüssel den Server nie |
|
||||||
| Eingabe | Passwortfeld, schreibt nur; ein bestehender Wert wird ersetzt |
|
| Eingabe | Passwortfeld, schreibt nur; ein bestehender Wert wird ersetzt |
|
||||||
| Entfernen | löscht den Wert in der Datenbank; steht in der `.env` einer, gilt wieder dieser |
|
| Entfernen | löscht den Wert in der Datenbank; steht in der `.env` einer, gilt wieder dieser |
|
||||||
| Modell | frei setzbar, Vorgabe `gpt-5.6-luna` |
|
| Modell | Auswahlliste der Chat-Modelle, die der Schlüssel nutzen darf (siehe unten); Vorgabe `gpt-5.6-luna` |
|
||||||
| Test | ein Miniaufruf gegen das Modell – sagt, ob Schlüssel, Modellname und Kontingent zusammenpassen |
|
| Test | ein Miniaufruf gegen das Modell – sagt, ob Schlüssel, Modellname und Kontingent zusammenpassen |
|
||||||
|
|
||||||
In der Historie landet, **dass** jemand den Schlüssel gewechselt hat, nie der
|
In der Historie landet, **dass** jemand den Schlüssel gewechselt hat, nie der
|
||||||
Wert. Ein stiller Wechsel wäre später nicht mehr nachvollziehbar.
|
Wert. Ein stiller Wechsel wäre später nicht mehr nachvollziehbar.
|
||||||
|
|
||||||
|
### Modellauswahl
|
||||||
|
|
||||||
|
Statt eines freien Textfelds zeigt der Reiter ein Dropdown. Die Liste kommt von
|
||||||
|
`GET /api/ai/models`, das Backend fragt dafür OpenAI `GET /v1/models` mit dem
|
||||||
|
hinterlegten Schlüssel (`listAiModels()` in `ai.js`) – angezeigt wird also genau
|
||||||
|
das, was dieser Schlüssel darf.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant B as Browser (AiSettings)
|
||||||
|
participant S as Backend /api/ai/models
|
||||||
|
participant O as OpenAI /v1/models
|
||||||
|
B->>S: GET (admin)
|
||||||
|
alt Liste im Speicher und < 10 min alt, gleicher Schlüssel
|
||||||
|
S-->>B: { models }
|
||||||
|
else
|
||||||
|
S->>O: GET mit Bearer-Schlüssel
|
||||||
|
O-->>S: alle Modelle
|
||||||
|
S->>S: nur Chat-Modelle, neueste zuerst
|
||||||
|
S-->>B: { models }
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
| Regel | Umsetzung |
|
||||||
|
| --- | --- |
|
||||||
|
| Nur Chat-Modelle | ID beginnt mit `gpt-`, `o<Ziffer>` oder `chatgpt-`; raus fallen Embedding, Whisper, TTS, Bild, Audio, Realtime, Transcribe, Moderation, Search, Instruct, Codex, Computer-Use, Sora |
|
||||||
|
| Reihenfolge | nach `created` absteigend – neueste oben |
|
||||||
|
| Zwischenspeicher | 10 Minuten im Speicher; ein anderer Schlüssel lädt sofort neu |
|
||||||
|
| Aktives Modell nicht mehr gelistet | bleibt im Dropdown, markiert „– nicht verfügbar" |
|
||||||
|
| Vorgabemodell | markiert „(Vorgabe)" |
|
||||||
|
| Liste nicht abrufbar (kein Schlüssel, OpenAI down) | Rückfall auf das Textfeld mit Hinweis und *Erneut versuchen* |
|
||||||
|
| Schlüssel gespeichert/entfernt | Liste wird neu geladen bzw. verworfen |
|
||||||
|
|
||||||
> **Fallstrick:** `gpt-5.6-luna` akzeptiert **kein eigenes `temperature`** und
|
> **Fallstrick:** `gpt-5.6-luna` akzeptiert **kein eigenes `temperature`** und
|
||||||
> weist jeden Wert außer dem Standard mit HTTP 400 ab. `ai.js` schickt deshalb
|
> weist jeden Wert außer dem Standard mit HTTP 400 ab. `ai.js` schickt deshalb
|
||||||
> keines. `response_format: {type:'json_object'}` funktioniert dagegen.
|
> keines. `response_format: {type:'json_object'}` funktioniert dagegen.
|
||||||
|
|||||||
@@ -159,9 +159,33 @@ Zwei Untermenüs:
|
|||||||
|
|
||||||
## Laufende Projekte
|
## Laufende Projekte
|
||||||
|
|
||||||
Titel, Kunde, Experte, Zeitraum, Status und Notizen. Die Notizen sind ein
|
Titel, Kunde, Experte, Zeitraum, Status, Tagessatz und Notizen. Die Notizen
|
||||||
WYSIWYG-Feld – im übernommenen Bestand steht dort ein Kopfblock aus dem alten
|
sind ein WYSIWYG-Feld – im übernommenen Bestand steht dort ein Kopfblock aus dem
|
||||||
CRM und darunter der Ausschreibungstext.
|
alten CRM und darunter der Ausschreibungstext.
|
||||||
|
|
||||||
|
### Marge
|
||||||
|
|
||||||
|
Jede Projektkachel weist die Marge aus, und in der Projektkarte steht die
|
||||||
|
Rechnung aufgeschlüsselt:
|
||||||
|
|
||||||
|
| Zeile | Woher |
|
||||||
|
| --- | --- |
|
||||||
|
| Tagessatz Kunde | Feld **Tagessatz** am Projekt |
|
||||||
|
| Stundensatz Berater | Kontaktkarte des Experten |
|
||||||
|
| Kosten (8 h) | Stundensatz × 8 |
|
||||||
|
| Marge | Tagessatz − Kosten, dazu der Prozentsatz vom Tagessatz |
|
||||||
|
|
||||||
|
**Ein Tag sind 8 Stunden.** Hat der Berater einen Remote- *und* einen
|
||||||
|
Vor-Ort-Satz, wird mit dem **Remote-Satz** gerechnet; fehlt der, mit dem
|
||||||
|
Vor-Ort-Satz. Welcher benutzt wurde, steht daneben.
|
||||||
|
|
||||||
|
Die Farbe ist eine schnelle Ampel: grün ab 25 %, gelb darüber bis 0, rot bei
|
||||||
|
negativer Marge.
|
||||||
|
|
||||||
|
Fehlt der Tagessatz oder hat der Berater keinen Stundensatz, steht statt einer
|
||||||
|
Zahl der Grund dort – eine leere Zeile ließe offen, ob die Marge null oder
|
||||||
|
unbekannt ist. Der Tagessatz wird am Projekt gepflegt, die Stundensätze beim
|
||||||
|
Kontakt (*Kontakt → Stundensatz vor Ort / remote*).
|
||||||
|
|
||||||
## Historie
|
## Historie
|
||||||
|
|
||||||
@@ -191,7 +215,7 @@ nichts gespeichert. Details: [04 Diktat](04-diktat.md).
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Nutzerverwaltung | Administratoren | Benutzer anlegen, Rolle, aktiv/inaktiv |
|
| Nutzerverwaltung | Administratoren | Benutzer anlegen, Rolle, aktiv/inaktiv |
|
||||||
| Stammdaten | Administratoren | Auswahllisten: Branchen, Arten der Kontakte |
|
| Stammdaten | Administratoren | Auswahllisten: Branchen, Arten der Kontakte |
|
||||||
| KI | Administratoren | OpenAI-Schlüssel, Modell, Verbindungstest |
|
| KI | Administratoren | OpenAI-Schlüssel, Modell (Auswahlliste der für den Schlüssel verfügbaren Modelle), Verbindungstest |
|
||||||
| Mein Konto | alle | Foto, Farbe, Passwort, Design |
|
| Mein Konto | alle | Foto, Farbe, Passwort, Design |
|
||||||
| Diktat | alle | Spracherkennung einrichten |
|
| Diktat | alle | Spracherkennung einrichten |
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ auch Phasenwechsel, der Schalter `online` und die Zuweisung von Kontakten.
|
|||||||
|
|
||||||
`GET /`, `POST /`, `PATCH /:id`, `DELETE /:id`.
|
`GET /`, `POST /`, `PATCH /:id`, `DELETE /:id`.
|
||||||
|
|
||||||
|
`GET /` liefert zu jedem Projekt zusätzlich `contact_rate_onsite` und
|
||||||
|
`contact_rate_remote` – die Stundensätze des zugeordneten Beraters. Sie sind
|
||||||
|
nur für die Margen-Anzeige da und **nicht** über `PATCH` änderbar; gepflegt
|
||||||
|
werden sie am Kontakt.
|
||||||
|
|
||||||
## Leads – `/api/leads`
|
## Leads – `/api/leads`
|
||||||
|
|
||||||
| Endpunkt | Zweck |
|
| Endpunkt | Zweck |
|
||||||
@@ -102,6 +107,7 @@ auch Phasenwechsel, der Schalter `online` und die Zuweisung von Kontakten.
|
|||||||
| `GET /status` | `{ configured, model }` – steuert, ob die Knöpfe erscheinen |
|
| `GET /status` | `{ configured, model }` – steuert, ob die Knöpfe erscheinen |
|
||||||
| `POST /text` | Schreibhilfe: `{ task, text, prompt }` |
|
| `POST /text` | Schreibhilfe: `{ task, text, prompt }` |
|
||||||
| `GET /config` | *admin* – Zustand der Einrichtung; der Schlüssel nur verkürzt |
|
| `GET /config` | *admin* – Zustand der Einrichtung; der Schlüssel nur verkürzt |
|
||||||
|
| `GET /models` | *admin* – `{ models: [{ id, created }] }`, Chat-Modelle des hinterlegten Schlüssels, neueste zuerst (10 min zwischengespeichert) |
|
||||||
| `PATCH /config` | *admin* – `{ key?, model? }`; `key: ''` entfernt ihn wieder |
|
| `PATCH /config` | *admin* – `{ key?, model? }`; `key: ''` entfernt ihn wieder |
|
||||||
| `POST /test` | *admin* – Miniaufruf gegen das Modell |
|
| `POST /test` | *admin* – Miniaufruf gegen das Modell |
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,49 @@ export async function saveAiModel(model) {
|
|||||||
config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL;
|
config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Modelle, die der hinterlegte Schluessel nutzen darf.
|
||||||
|
*
|
||||||
|
* /v1/models liefert alles - auch Bild-, Audio-, Embedding- und Moderations-
|
||||||
|
* modelle. Fuer das CRM zaehlen nur Chat-Modelle, also werden die anderen
|
||||||
|
* ausgefiltert. Kurz im Speicher gehalten, damit das Aufklappen der Liste nicht
|
||||||
|
* jedes Mal OpenAI fragt; ein Schluesselwechsel leert den Speicher.
|
||||||
|
*/
|
||||||
|
const MODELS_ENDPOINT = 'https://api.openai.com/v1/models';
|
||||||
|
const MODELS_TTL_MS = 10 * 60_000;
|
||||||
|
const NON_CHAT = /(embedding|whisper|tts|dall-e|image|audio|realtime|transcribe|moderation|search|instruct|davinci|babbage|computer-use|codex|sora)/i;
|
||||||
|
let modelCache = { key: '', at: 0, list: [] };
|
||||||
|
|
||||||
|
export async function listAiModels() {
|
||||||
|
if (!aiConfigured()) throw new AiError('Es ist kein Schlüssel hinterlegt.', 503);
|
||||||
|
if (modelCache.key === config.key && Date.now() - modelCache.at < MODELS_TTL_MS) {
|
||||||
|
return modelCache.list;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(MODELS_ENDPOINT, {
|
||||||
|
headers: { Authorization: `Bearer ${config.key}` },
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new AiError('OpenAI ist gerade nicht erreichbar.', 502);
|
||||||
|
}
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = (payload?.data ?? [])
|
||||||
|
.map((m) => ({ id: String(m.id), created: Number(m.created) || 0 }))
|
||||||
|
.filter((m) => /^(gpt-|o\d|chatgpt-)/i.test(m.id) && !NON_CHAT.test(m.id))
|
||||||
|
// Neueste zuerst - die will man meistens.
|
||||||
|
.sort((a, b) => b.created - a.created || a.id.localeCompare(b.id));
|
||||||
|
|
||||||
|
modelCache = { key: config.key, at: Date.now(), list };
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
class AiError extends Error {
|
class AiError extends Error {
|
||||||
constructor(message, status = 502) {
|
constructor(message, status = 502) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { pool } from './db.js';
|
||||||
|
import { logHistory } from './history.js';
|
||||||
|
import { normalizeAvatar } from './userFields.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Der Feldabgleich mit dem perinco CRM - eine Stelle fuer beide Richtungen.
|
||||||
|
*
|
||||||
|
* Derselbe Mensch steht in beiden Haeusern. Unterscheidet sich etwas - andere
|
||||||
|
* Telefonnummer, fehlendes Foto -, soll der Unterschied in BEIDEN Systemen
|
||||||
|
* verschwinden und nicht nur in dem, in dem gerade jemand sitzt.
|
||||||
|
*
|
||||||
|
* Geschrieben wird von zwei Seiten:
|
||||||
|
* routes/integrations.js das perinco CRM schreibt hierher (fester Token)
|
||||||
|
* routes/perinco.js ein Benutzer hier gleicht ab und schreibt mit
|
||||||
|
* Beide benutzen diese Abbildung, damit die Felder nicht auseinanderlaufen.
|
||||||
|
*
|
||||||
|
* Bewusst eng gefasst: nur die Felder, die die Bruecke ohnehin herausgibt.
|
||||||
|
* Art, Status, Saetze, Werdegang und Lebenslauf sind nicht erreichbar - die
|
||||||
|
* kennt `crm_leads` nicht, und drueben hat sie niemand zu entscheiden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const trimOrNull = (value, max) => {
|
||||||
|
const text = String(value ?? '').trim();
|
||||||
|
return text ? text.slice(0, max) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Wie in routes/contacts.js: getrimmt, ohne Leereintraege, ohne Duplikate. */
|
||||||
|
export function normalizeTags(input) {
|
||||||
|
const list = Array.isArray(input) ? input : String(input ?? '').split(',');
|
||||||
|
const seen = [];
|
||||||
|
for (const raw of list) {
|
||||||
|
const tag = String(raw).trim();
|
||||||
|
if (tag && !seen.some((t) => t.toLowerCase() === tag.toLowerCase())) seen.push(tag);
|
||||||
|
}
|
||||||
|
return seen.length > 0 ? seen.join(', ').slice(0, 1000) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feldname der Bruecke -> Spalte hier, samt Umformung in die eigene Ablage.
|
||||||
|
*
|
||||||
|
* Die Namen sind die der Schnittstelle (`notes`, `source`), nicht die der
|
||||||
|
* eigenen Tabelle - auf beiden Seiten wird dieselbe Sprache gesprochen.
|
||||||
|
*/
|
||||||
|
export const BRIDGE_FIELDS = {
|
||||||
|
first_name: { column: 'first_name', parse: (v) => trimOrNull(v, 120) },
|
||||||
|
last_name: { column: 'last_name', parse: (v) => trimOrNull(v, 120) },
|
||||||
|
position: { column: 'position', parse: (v) => trimOrNull(v, 190) },
|
||||||
|
email: { column: 'email', parse: (v) => trimOrNull(v, 190) },
|
||||||
|
phone: { column: 'phone', parse: (v) => trimOrNull(v, 80) },
|
||||||
|
linkedin: { column: 'linkedin', parse: (v) => trimOrNull(v, 300) },
|
||||||
|
source: { column: 'lead_source', parse: (v) => trimOrNull(v, 190) },
|
||||||
|
notes: { column: 'bio', parse: (v) => (v ? String(v) : null) },
|
||||||
|
tags: { column: 'tags', parse: (v) => normalizeTags(v) },
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Firma fehlt hier mit Absicht.
|
||||||
|
*
|
||||||
|
* Drueben ist sie ein Textfeld, hier ein Verweis auf icon_companies. Ein Name
|
||||||
|
* von drueben liesse sich nur raten - entweder als neue Firma anlegen (und aus
|
||||||
|
* jeder Schreibweise eine eigene machen) oder still verwerfen. Beides ist
|
||||||
|
* schlechter, als die Firma dort zu lassen, wo sie gepflegt wird.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wendet einen Abgleich auf einen Kontakt an.
|
||||||
|
*
|
||||||
|
* @param {object} params
|
||||||
|
* @param {number} params.id Kontakt hier
|
||||||
|
* @param {object} params.body Felder in der Sprache der Bruecke
|
||||||
|
* @param {number|null} params.userId Wer abgleicht; null = die Schnittstelle
|
||||||
|
* @param {string} params.note Text fuer die Historie
|
||||||
|
* @returns {Promise<{status: number, body: object}>}
|
||||||
|
*/
|
||||||
|
export async function applyBridgePatch({ id, body, userId = null, note }) {
|
||||||
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
|
return { status: 400, body: { error: 'Ungültige Kennung.' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {};
|
||||||
|
for (const [field, config] of Object.entries(BRIDGE_FIELDS)) {
|
||||||
|
if (body?.[field] === undefined) continue;
|
||||||
|
data[config.column] = config.parse(body[field]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Foto laeuft durch dieselbe Pruefung wie im eigenen Formular: von
|
||||||
|
// drueben kommt eine Data-URL, und die darf weder unbegrenzt gross noch
|
||||||
|
// beliebigen Typs sein.
|
||||||
|
if (body?.avatar !== undefined) {
|
||||||
|
const avatar = normalizeAvatar(body.avatar);
|
||||||
|
if (avatar.error) return { status: 400, body: { error: avatar.error } };
|
||||||
|
data.avatar = avatar.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
return { status: 400, body: { error: 'Es wurde kein abgleichbares Feld übergeben.' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Kontakt ohne Nachnamen waere nicht mehr auffindbar - der Abgleich darf
|
||||||
|
// den Namen aendern, aber nicht loeschen.
|
||||||
|
if (data.last_name === null) {
|
||||||
|
return { status: 400, body: { error: 'Der Nachname darf nicht geleert werden.' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query('SELECT * FROM icon_contacts WHERE id = ? LIMIT 1', [id]);
|
||||||
|
const before = rows[0];
|
||||||
|
if (!before) return { status: 404, body: { error: 'Kontakt nicht gefunden.' } };
|
||||||
|
|
||||||
|
const columns = Object.keys(data);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE icon_contacts SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
|
||||||
|
[...columns.map((c) => data[c]), id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const changes = {};
|
||||||
|
for (const column of columns) {
|
||||||
|
if (String(before[column] ?? '') !== String(data[column] ?? '')) {
|
||||||
|
changes[column] = [before[column] ?? null, data[column] ?? null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(changes).length > 0) {
|
||||||
|
await logHistory({
|
||||||
|
entityType: 'contact',
|
||||||
|
entityId: id,
|
||||||
|
entityLabel: [before.title, before.first_name, before.last_name].filter(Boolean).join(' '),
|
||||||
|
action: 'updated',
|
||||||
|
text: note,
|
||||||
|
changes,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 200, body: { ok: true, changed: Object.keys(changes) } };
|
||||||
|
}
|
||||||
+46
-1
@@ -50,6 +50,7 @@ export async function initDatabase() {
|
|||||||
await migrateColumns();
|
await migrateColumns();
|
||||||
await migrateContactKind();
|
await migrateContactKind();
|
||||||
await seedOptions();
|
await seedOptions();
|
||||||
|
await renameCustomerContactKind();
|
||||||
await ensureAdminUser();
|
await ensureAdminUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ async function migrateContactKind() {
|
|||||||
async function seedOptions() {
|
async function seedOptions() {
|
||||||
if ((await getSetting('options_seeded')) === 'true') return;
|
if ((await getSetting('options_seeded')) === 'true') return;
|
||||||
|
|
||||||
const kinds = ['Experte', 'Ansprechpartner', 'Lead'];
|
const kinds = ['Experte', 'Ansprechpartner (Kunde)', 'Lead'];
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT IGNORE INTO icon_options (field, value, position)
|
`INSERT IGNORE INTO icon_options (field, value, position)
|
||||||
VALUES ${kinds.map((_, index) => `('contact_kind', ?, ${index})`).join(', ')}`,
|
VALUES ${kinds.map((_, index) => `('contact_kind', ?, ${index})`).join(', ')}`,
|
||||||
@@ -112,6 +113,44 @@ async function seedOptions() {
|
|||||||
console.log(`[ICON CRM] Auswahllisten angelegt - ${count.count} Eintrag/Einträge.`);
|
console.log(`[ICON CRM] Auswahllisten angelegt - ${count.count} Eintrag/Einträge.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* "Ansprechpartner" heisst jetzt "Ansprechpartner (Kunde)" - der Zusatz sagt,
|
||||||
|
* wessen Ansprechpartner gemeint ist. Umbenannt wird beides: der Eintrag in der
|
||||||
|
* Auswahlliste und jeder Kontakt, der die alte Art traegt.
|
||||||
|
*
|
||||||
|
* Laeuft genau einmal, festgehalten per Merker. Ohne den wuerde eine spaetere
|
||||||
|
* Umbenennung durch den Administrator bei jedem Neustart zurueckgedreht.
|
||||||
|
*/
|
||||||
|
async function renameCustomerContactKind() {
|
||||||
|
if ((await getSetting('contact_kind_customer_renamed')) === 'true') return;
|
||||||
|
|
||||||
|
const OLD = 'Ansprechpartner';
|
||||||
|
const NEW = 'Ansprechpartner (Kunde)';
|
||||||
|
|
||||||
|
// Steht der neue Wert schon in der Liste, waere das Umbenennen ein Verstoss
|
||||||
|
// gegen den eindeutigen Schluessel (field, value) - dann faellt der alte weg.
|
||||||
|
const [existing] = await pool.query(
|
||||||
|
'SELECT id FROM icon_options WHERE field = ? AND value = ? LIMIT 1',
|
||||||
|
['contact_kind', NEW]
|
||||||
|
);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await pool.query('DELETE FROM icon_options WHERE field = ? AND value = ?', ['contact_kind', OLD]);
|
||||||
|
} else {
|
||||||
|
await pool.query('UPDATE icon_options SET value = ? WHERE field = ? AND value = ?', [
|
||||||
|
NEW,
|
||||||
|
'contact_kind',
|
||||||
|
OLD,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await pool.query('UPDATE icon_contacts SET kind = ? WHERE kind = ?', [NEW, OLD]);
|
||||||
|
await setSetting('contact_kind_customer_renamed', 'true');
|
||||||
|
|
||||||
|
if (result.affectedRows > 0) {
|
||||||
|
console.log(`[ICON CRM] Kontaktart "${OLD}" in "${NEW}" umbenannt - ${result.affectedRows} Kontakt(e).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Spalten, die zu einer bereits bestehenden Tabelle dazugekommen sind.
|
* Spalten, die zu einer bereits bestehenden Tabelle dazugekommen sind.
|
||||||
*
|
*
|
||||||
@@ -134,6 +173,12 @@ async function migrateColumns() {
|
|||||||
],
|
],
|
||||||
// Das im Lebenslauf gefundene Portrait - Zwischenablage zwischen Auslesen
|
// Das im Lebenslauf gefundene Portrait - Zwischenablage zwischen Auslesen
|
||||||
// und Uebernehmen (siehe server/src/cvPhoto.js).
|
// und Uebernehmen (siehe server/src/cvPhoto.js).
|
||||||
|
// Akademischer Titel des Kontakts ("Dr.", "Prof. Dr.") - Freitext.
|
||||||
|
[
|
||||||
|
'icon_contacts',
|
||||||
|
'title',
|
||||||
|
'ALTER TABLE icon_contacts ADD COLUMN title VARCHAR(60) NULL AFTER id',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'icon_cv_imports',
|
'icon_cv_imports',
|
||||||
'photo',
|
'photo',
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export function diffFields(before, after, fields) {
|
|||||||
/** Menschenlesbare Namen der Felder - fuer den Text in der Historie. */
|
/** Menschenlesbare Namen der Felder - fuer den Text in der Historie. */
|
||||||
export const FIELD_LABELS = {
|
export const FIELD_LABELS = {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
|
title: 'Titel',
|
||||||
first_name: 'Vorname',
|
first_name: 'Vorname',
|
||||||
last_name: 'Nachname',
|
last_name: 'Nachname',
|
||||||
position: 'Position',
|
position: 'Position',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
aiConfigured,
|
aiConfigured,
|
||||||
aiModel,
|
aiModel,
|
||||||
aiStatus,
|
aiStatus,
|
||||||
|
listAiModels,
|
||||||
saveAiKey,
|
saveAiKey,
|
||||||
saveAiModel,
|
saveAiModel,
|
||||||
} from '../ai.js';
|
} from '../ai.js';
|
||||||
@@ -116,6 +117,15 @@ router.get('/config', requireAdmin, (req, res) => {
|
|||||||
res.json(aiStatus());
|
res.json(aiStatus());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/ai/models - Chat-Modelle, die der hinterlegte Schluessel nutzen darf.
|
||||||
|
router.get('/models', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ models: await listAiModels() });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(err?.status ?? 502).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// PATCH /api/ai/config - { key?, model? }; key === '' entfernt ihn wieder.
|
// PATCH /api/ai/config - { key?, model? }; key === '' entfernt ihn wieder.
|
||||||
router.patch('/config', requireAdmin, async (req, res, next) => {
|
router.patch('/config', requireAdmin, async (req, res, next) => {
|
||||||
const body = req.body || {};
|
const body = req.body || {};
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ router.get('/:id', async (req, res, next) => {
|
|||||||
|
|
||||||
const [interviews] = await pool.query(
|
const [interviews] = await pool.query(
|
||||||
`SELECT i.*, u.name AS user_name,
|
`SELECT i.*, u.name AS user_name,
|
||||||
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name
|
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact_name
|
||||||
FROM icon_interviews i
|
FROM icon_interviews i
|
||||||
LEFT JOIN icon_users u ON u.id = i.user_id
|
LEFT JOIN icon_users u ON u.id = i.user_id
|
||||||
LEFT JOIN icon_contacts k ON k.id = i.contact_id
|
LEFT JOIN icon_contacts k ON k.id = i.contact_id
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ router.get('/', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT c.*,
|
`SELECT c.*,
|
||||||
CONCAT_WS(' ', a.first_name, a.last_name) AS a_name,
|
CONCAT_WS(' ', a.title, a.first_name, a.last_name) AS a_name,
|
||||||
CONCAT_WS(' ', b.first_name, b.last_name) AS b_name
|
CONCAT_WS(' ', b.title, b.first_name, b.last_name) AS b_name
|
||||||
FROM icon_connections c
|
FROM icon_connections c
|
||||||
JOIN icon_contacts a ON a.id = c.contact_a
|
JOIN icon_contacts a ON a.id = c.contact_a
|
||||||
JOIN icon_contacts b ON b.id = c.contact_b
|
JOIN icon_contacts b ON b.id = c.contact_b
|
||||||
@@ -50,7 +50,7 @@ router.post('/', async (req, res, next) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [names] = await pool.query(
|
const [names] = await pool.query(
|
||||||
"SELECT id, CONCAT_WS(' ', first_name, last_name) AS name FROM icon_contacts WHERE id IN (?, ?)",
|
"SELECT id, CONCAT_WS(' ', title, first_name, last_name) AS name FROM icon_contacts WHERE id IN (?, ?)",
|
||||||
[low, high]
|
[low, high]
|
||||||
);
|
);
|
||||||
const nameOf = (id) => names.find((row) => row.id === id)?.name ?? 'Unbekannt';
|
const nameOf = (id) => names.find((row) => row.id === id)?.name ?? 'Unbekannt';
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ router.use(requireAuth);
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const EDITABLE = [
|
const EDITABLE = [
|
||||||
|
'title',
|
||||||
'first_name',
|
'first_name',
|
||||||
'last_name',
|
'last_name',
|
||||||
'position',
|
'position',
|
||||||
@@ -58,6 +59,7 @@ async function assertKnownKind(kind) {
|
|||||||
|
|
||||||
function readBody(body) {
|
function readBody(body) {
|
||||||
const out = {};
|
const out = {};
|
||||||
|
if (body.title !== undefined) out.title = trimOrNull(body.title, 60);
|
||||||
if (body.first_name !== undefined) out.first_name = trimOrNull(body.first_name, 120);
|
if (body.first_name !== undefined) out.first_name = trimOrNull(body.first_name, 120);
|
||||||
if (body.last_name !== undefined) out.last_name = String(body.last_name ?? '').trim().slice(0, 120);
|
if (body.last_name !== undefined) out.last_name = String(body.last_name ?? '').trim().slice(0, 120);
|
||||||
if (body.position !== undefined) out.position = trimOrNull(body.position, 190);
|
if (body.position !== undefined) out.position = trimOrNull(body.position, 190);
|
||||||
@@ -122,7 +124,7 @@ router.get('/', async (req, res, next) => {
|
|||||||
const q = String(req.query.q || '').trim();
|
const q = String(req.query.q || '').trim();
|
||||||
if (q) {
|
if (q) {
|
||||||
where.push(
|
where.push(
|
||||||
`(CONCAT_WS(' ', k.first_name, k.last_name) LIKE ? OR k.email LIKE ? OR k.position LIKE ?
|
`(CONCAT_WS(' ', k.title, k.first_name, k.last_name) LIKE ? OR k.email LIKE ? OR k.position LIKE ?
|
||||||
OR k.tags LIKE ? OR k.bio LIKE ? OR co.name LIKE ?)`
|
OR k.tags LIKE ? OR k.bio LIKE ? OR co.name LIKE ?)`
|
||||||
);
|
);
|
||||||
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
|
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
|
||||||
@@ -663,7 +665,7 @@ router.get('/:id/cv', async (req, res, next) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT k.cv_file, CONCAT_WS(' ', k.first_name, k.last_name) AS name,
|
`SELECT k.cv_file, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS name,
|
||||||
(SELECT original_name FROM icon_cv_imports i WHERE i.filename = k.cv_file LIMIT 1) AS original_name
|
(SELECT original_name FROM icon_cv_imports i WHERE i.filename = k.cv_file LIMIT 1) AS original_name
|
||||||
FROM icon_contacts k WHERE k.id = ? LIMIT 1`,
|
FROM icon_contacts k WHERE k.id = ? LIMIT 1`,
|
||||||
[id]
|
[id]
|
||||||
@@ -777,7 +779,9 @@ async function loadOne(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fullName(row) {
|
function fullName(row) {
|
||||||
return [row.first_name, row.last_name].filter(Boolean).join(' ').trim() || 'Unbenannt';
|
// Der Titel gehoert zum Namen: er steht in Liste, Karte und Erwaehnung genauso
|
||||||
|
// davor wie auf der Visitenkarte.
|
||||||
|
return [row.title, row.first_name, row.last_name].filter(Boolean).join(' ').trim() || 'Unbenannt';
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRow(row) {
|
function normalizeRow(row) {
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ router.get('/', async (req, res, next) => {
|
|||||||
c.extracted, c.error, c.contact_id, c.uploaded_by, c.created_at,
|
c.extracted, c.error, c.contact_id, c.uploaded_by, c.created_at,
|
||||||
c.processed_at, c.photo IS NOT NULL AS has_photo,
|
c.processed_at, c.photo IS NOT NULL AS has_photo,
|
||||||
u.name AS uploaded_by_name,
|
u.name AS uploaded_by_name,
|
||||||
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name
|
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact_name
|
||||||
FROM icon_cv_imports c
|
FROM icon_cv_imports c
|
||||||
LEFT JOIN icon_users u ON u.id = c.uploaded_by
|
LEFT JOIN icon_users u ON u.id = c.uploaded_by
|
||||||
LEFT JOIN icon_contacts k ON k.id = c.contact_id
|
LEFT JOIN icon_contacts k ON k.id = c.contact_id
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ router.get('/', async (req, res, next) => {
|
|||||||
`SELECT
|
`SELECT
|
||||||
(SELECT COUNT(*) FROM icon_contacts) AS contacts,
|
(SELECT COUNT(*) FROM icon_contacts) AS contacts,
|
||||||
(SELECT COUNT(*) FROM icon_contacts WHERE kind = 'Experte') AS experts,
|
(SELECT COUNT(*) FROM icon_contacts WHERE kind = 'Experte') AS experts,
|
||||||
(SELECT COUNT(*) FROM icon_contacts WHERE kind = 'Ansprechpartner') AS partners,
|
(SELECT COUNT(*) FROM icon_contacts WHERE kind = 'Ansprechpartner (Kunde)') AS partners,
|
||||||
(SELECT COUNT(*) FROM icon_companies) AS companies,
|
(SELECT COUNT(*) FROM icon_companies) AS companies,
|
||||||
(SELECT COUNT(*) FROM icon_companies WHERE is_customer = 1 AND customer_state = 'aktiv') AS customers,
|
(SELECT COUNT(*) FROM icon_companies WHERE is_customer = 1 AND customer_state = 'aktiv') AS customers,
|
||||||
(SELECT COUNT(*) FROM icon_companies WHERE is_customer = 1 AND customer_state = 'alumni') AS alumni,
|
(SELECT COUNT(*) FROM icon_companies WHERE is_customer = 1 AND customer_state = 'alumni') AS alumni,
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ async function labelFor(entityType, entityId) {
|
|||||||
if (!entityId) return null;
|
if (!entityId) return null;
|
||||||
if (entityType === 'contact') {
|
if (entityType === 'contact') {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
"SELECT CONCAT_WS(' ', first_name, last_name) AS label FROM icon_contacts WHERE id = ?",
|
"SELECT CONCAT_WS(' ', title, first_name, last_name) AS label FROM icon_contacts WHERE id = ?",
|
||||||
[entityId]
|
[entityId]
|
||||||
);
|
);
|
||||||
return rows[0]?.label ?? null;
|
return rows[0]?.label ?? null;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { timingSafeEqual } from 'node:crypto';
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
import { pool } from '../db.js';
|
import { pool } from '../db.js';
|
||||||
|
import { applyBridgePatch } from '../contactBridge.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -13,6 +14,9 @@ const router = express.Router();
|
|||||||
*
|
*
|
||||||
* Ausgeliefert wird nur, was drüben ein Feld hat. `crm_leads` kennt keinen
|
* Ausgeliefert wird nur, was drüben ein Feld hat. `crm_leads` kennt keinen
|
||||||
* Status, keine Tagessätze und keine Lebensläufe - die bleiben hier.
|
* Status, keine Tagessätze und keine Lebensläufe - die bleiben hier.
|
||||||
|
*
|
||||||
|
* Seit dem Abgleich doppelter Kontakte gibt es zusätzlich einen Schreibweg
|
||||||
|
* (PATCH). Er ist bewusst eng gefasst - siehe die Begründung dort.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function tokenMatches(given) {
|
function tokenMatches(given) {
|
||||||
@@ -82,4 +86,30 @@ router.get('/contacts', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Schreibweg: ein Feldabgleich von drüben.
|
||||||
|
*
|
||||||
|
* Nötig geworden, weil derselbe Mensch in beiden Häusern steht und ein
|
||||||
|
* Unterschied in BEIDEN Systemen verschwinden soll. Welche Felder erreichbar
|
||||||
|
* sind und warum nicht mehr, steht in contactBridge.js.
|
||||||
|
*
|
||||||
|
* Hier hängt ein fester Token statt einer Anmeldung, deshalb: nur an einem
|
||||||
|
* bereits vorhandenen Kontakt, kein Anlegen, kein Löschen, keine
|
||||||
|
* Sammeländerung.
|
||||||
|
*/
|
||||||
|
router.patch('/contacts/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await applyBridgePatch({
|
||||||
|
id: Number(req.params.id),
|
||||||
|
body: req.body || {},
|
||||||
|
// Kein Benutzer - es war keiner, es war die Schnittstelle.
|
||||||
|
userId: null,
|
||||||
|
note: 'Abgleich aus dem perinco CRM',
|
||||||
|
});
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ const positiveOrNull = (value) => {
|
|||||||
const SELECT = `
|
const SELECT = `
|
||||||
SELECT l.*, co.name AS company_name, co.industry AS company_industry, co.logo AS company_logo,
|
SELECT l.*, co.name AS company_name, co.industry AS company_industry, co.logo AS company_logo,
|
||||||
co.rating AS company_rating,
|
co.rating AS company_rating,
|
||||||
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name,
|
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact_name,
|
||||||
k.email AS contact_email, k.phone AS contact_phone, k.avatar AS contact_avatar,
|
k.email AS contact_email, k.phone AS contact_phone, k.avatar AS contact_avatar,
|
||||||
u.name AS owner_name,
|
u.name AS owner_name,
|
||||||
(SELECT COUNT(*) FROM icon_lead_todos t WHERE t.lead_id = l.id) AS todo_count,
|
(SELECT COUNT(*) FROM icon_lead_todos t WHERE t.lead_id = l.id) AS todo_count,
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ router.get('/', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [contacts] = await pool.query(
|
const [contacts] = await pool.query(
|
||||||
`SELECT DISTINCT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS name,
|
`SELECT DISTINCT k.id, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS name,
|
||||||
k.position, k.avatar, k.kind, k.status, k.company_id
|
k.position, k.avatar, k.kind, k.status, k.company_id
|
||||||
FROM icon_contacts k
|
FROM icon_contacts k
|
||||||
LEFT JOIN icon_employments e ON e.contact_id = k.id
|
LEFT JOIN icon_employments e ON e.contact_id = k.id
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { applyBridgePatch } from '../contactBridge.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
@@ -7,12 +8,16 @@ router.use(requireAuth);
|
|||||||
/*
|
/*
|
||||||
* Lesender Zugriff auf die Kontakte des perinco CRM.
|
* Lesender Zugriff auf die Kontakte des perinco CRM.
|
||||||
*
|
*
|
||||||
* Es wird NICHTS gespeichert. Die Kontakte gehören dem perinco CRM, werden dort
|
* Es wird NICHTS gespiegelt. Die Kontakte gehören dem perinco CRM, werden dort
|
||||||
* gepflegt und hier nur angezeigt - gekennzeichnet, nicht vermischt. Ein
|
* gepflegt und hier nur angezeigt - gekennzeichnet, nicht vermischt. Ein
|
||||||
* nächtlicher Abgleich in die eigene Datenbank wäre der falsche Weg: dann
|
* nächtlicher Abgleich in die eigene Datenbank wäre der falsche Weg: dann
|
||||||
* stünde derselbe Mensch in zwei Systemen, und irgendwann wäre zu klären,
|
* stünde derselbe Mensch in zwei Systemen, und irgendwann wäre zu klären,
|
||||||
* welcher Eintrag gilt.
|
* welcher Eintrag gilt.
|
||||||
*
|
*
|
||||||
|
* Eine Ausnahme gibt es: steht derselbe Mensch nachweislich in beiden Häusern,
|
||||||
|
* kann ein Benutzer die Unterschiede abgleichen. Dann wird auch nach drüben
|
||||||
|
* geschrieben - Feld für Feld und nur, was er selbst entschieden hat.
|
||||||
|
*
|
||||||
* Die Felder werden auf die vorhandenen Felder eines ICON-Kontakts abgebildet.
|
* Die Felder werden auf die vorhandenen Felder eines ICON-Kontakts abgebildet.
|
||||||
* Neue Felder anzulegen war ausdrücklich nicht gewollt - was drüben kein
|
* Neue Felder anzulegen war ausdrücklich nicht gewollt - was drüben kein
|
||||||
* Gegenstück hat (Anrede, Anschrift, Website), bleibt drüben.
|
* Gegenstück hat (Anrede, Anschrift, Website), bleibt drüben.
|
||||||
@@ -131,4 +136,126 @@ function toIconShape(row) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einen Kontakt drueben aendern.
|
||||||
|
*
|
||||||
|
* Gegenstueck zu `PATCH /api/integrations/contacts/:id` hier - dieselbe enge
|
||||||
|
* Feldliste, derselbe feste Token. Wirft nicht, sondern meldet den Grund:
|
||||||
|
* der Aufrufer muss wissen, ob die andere Seite geschrieben hat, bevor er
|
||||||
|
* hier schreibt.
|
||||||
|
*/
|
||||||
|
async function patchRemoteContact(remoteId, fields) {
|
||||||
|
if (!CONFIGURED()) return { ok: false, error: 'Der Zugriff auf das perinco CRM ist nicht eingerichtet.' };
|
||||||
|
|
||||||
|
const base = String(process.env.PERINCO_API_URL).replace(/\/+$/, '');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 15_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${base}/api/integrations/contacts/${remoteId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.PERINCO_API_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(fields),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
payload?.error ??
|
||||||
|
(response.status === 401
|
||||||
|
? 'Das perinco CRM hat den Zugang abgelehnt (Token stimmt nicht).'
|
||||||
|
: `Das perinco CRM antwortete mit HTTP ${response.status}.`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, body: await response.json().catch(() => ({})) };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
err?.name === 'AbortError'
|
||||||
|
? 'Das perinco CRM hat nicht rechtzeitig geantwortet.'
|
||||||
|
: 'Das perinco CRM ist gerade nicht erreichbar.',
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Der Abgleich eines doppelt gefuehrten Kontakts.
|
||||||
|
*
|
||||||
|
* `local` sind die Felder, die hier gelten sollen, `remote` die, die drueben
|
||||||
|
* gelten sollen - beide in der Sprache der Bruecke und beide ausschliesslich
|
||||||
|
* aus dem, was der Benutzer im Dialog entschieden hat.
|
||||||
|
*
|
||||||
|
* Zuerst drueben, dann hier. Der Weg ueber das Netz ist der, der scheitern
|
||||||
|
* kann; scheitert er, ist hier noch nichts passiert und der Benutzer kann es
|
||||||
|
* unveraendert erneut versuchen. Andersherum bliebe ein halber Abgleich stehen.
|
||||||
|
*/
|
||||||
|
router.post('/contacts/:remoteId/merge', async (req, res, next) => {
|
||||||
|
// Die Kennung kommt mit Praefix aus der Oberflaeche ("perinco-31").
|
||||||
|
const remoteId = Number(String(req.params.remoteId).replace(/^perinco-/, ''));
|
||||||
|
const contactId = Number(req.body?.contact_id);
|
||||||
|
const local = req.body?.local ?? {};
|
||||||
|
const remote = req.body?.remote ?? {};
|
||||||
|
|
||||||
|
if (!Number.isInteger(remoteId) || remoteId <= 0) {
|
||||||
|
return res.status(400).json({ error: 'Ungültige Kennung im perinco CRM.' });
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(contactId) || contactId <= 0) {
|
||||||
|
return res.status(400).json({ error: 'Ungültige Kennung des Kontakts.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const wantsRemote = Object.keys(remote).length > 0;
|
||||||
|
const wantsLocal = Object.keys(local).length > 0;
|
||||||
|
if (!wantsRemote && !wantsLocal) {
|
||||||
|
return res.status(400).json({ error: 'Es wurde nichts zum Abgleichen ausgewählt.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let remoteChanged = [];
|
||||||
|
if (wantsRemote) {
|
||||||
|
const result = await patchRemoteContact(remoteId, remote);
|
||||||
|
if (!result.ok) return res.status(502).json({ error: result.error });
|
||||||
|
remoteChanged = result.body?.changed ?? [];
|
||||||
|
// Der Zwischenspeicher zeigt sonst bis zu einer Minute den alten Stand.
|
||||||
|
cache = { at: 0, contacts: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let localChanged = [];
|
||||||
|
if (wantsLocal) {
|
||||||
|
const result = await applyBridgePatch({
|
||||||
|
id: contactId,
|
||||||
|
body: local,
|
||||||
|
userId: req.user.id,
|
||||||
|
note: 'Abgleich mit dem perinco CRM',
|
||||||
|
});
|
||||||
|
if (result.status !== 200) {
|
||||||
|
// Drueben steht die Aenderung dann schon - das gehoert gesagt, sonst
|
||||||
|
// sucht jemand den Unterschied auf der falschen Seite.
|
||||||
|
return res.status(result.status).json({
|
||||||
|
error: result.body.error,
|
||||||
|
remote_changed: remoteChanged,
|
||||||
|
hint: remoteChanged.length > 0
|
||||||
|
? 'Im perinco CRM wurde bereits geschrieben, hier nicht.'
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
localChanged = result.body.changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, local_changed: localChanged, remote_changed: remoteChanged });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const positiveOrNull = (value) => {
|
|||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
return Number.isInteger(n) && n > 0 ? n : null;
|
return Number.isInteger(n) && n > 0 ? n : null;
|
||||||
};
|
};
|
||||||
|
const num = (value) => (value === null || value === undefined ? null : Number(value));
|
||||||
const dateOrNull = (value) => {
|
const dateOrNull = (value) => {
|
||||||
const text = String(value ?? '').slice(0, 10);
|
const text = String(value ?? '').slice(0, 10);
|
||||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null;
|
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null;
|
||||||
@@ -41,7 +42,7 @@ router.get('/', async (req, res, next) => {
|
|||||||
const q = String(req.query.q || '').trim();
|
const q = String(req.query.q || '').trim();
|
||||||
if (q) {
|
if (q) {
|
||||||
where.push(
|
where.push(
|
||||||
"(p.title LIKE ? OR co.name LIKE ? OR CONCAT_WS(' ', k.first_name, k.last_name) LIKE ?)"
|
"(p.title LIKE ? OR co.name LIKE ? OR CONCAT_WS(' ', k.title, k.first_name, k.last_name) LIKE ?)"
|
||||||
);
|
);
|
||||||
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
|
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,8 @@ router.get('/', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT p.*, co.name AS company_name,
|
`SELECT p.*, co.name AS company_name,
|
||||||
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name, k.avatar AS contact_avatar
|
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact_name, k.avatar AS contact_avatar,
|
||||||
|
k.rate_onsite AS contact_rate_onsite, k.rate_remote AS contact_rate_remote
|
||||||
FROM icon_projects p
|
FROM icon_projects p
|
||||||
LEFT JOIN icon_companies co ON co.id = p.company_id
|
LEFT JOIN icon_companies co ON co.id = p.company_id
|
||||||
LEFT JOIN icon_contacts k ON k.id = p.contact_id
|
LEFT JOIN icon_contacts k ON k.id = p.contact_id
|
||||||
@@ -61,7 +63,16 @@ router.get('/', async (req, res, next) => {
|
|||||||
ORDER BY p.started_on DESC, p.id DESC`,
|
ORDER BY p.started_on DESC, p.id DESC`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
res.json({ projects: rows.map((row) => ({ ...row, rate: row.rate === null ? null : Number(row.rate) })) });
|
// Die Saetze des Beraters kommen mit, damit die Liste die Marge ohne einen
|
||||||
|
// zweiten Aufruf ausweisen kann - ein Tagessatz ohne Kosten sagt nichts.
|
||||||
|
res.json({
|
||||||
|
projects: rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
rate: num(row.rate),
|
||||||
|
contact_rate_onsite: num(row.contact_rate_onsite),
|
||||||
|
contact_rate_remote: num(row.contact_rate_remote),
|
||||||
|
})),
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@@ -174,7 +185,7 @@ async function logRelated(data, text, userId) {
|
|||||||
}
|
}
|
||||||
if (data.contact_id) {
|
if (data.contact_id) {
|
||||||
const [[contact]] = await pool.query(
|
const [[contact]] = await pool.query(
|
||||||
"SELECT CONCAT_WS(' ', first_name, last_name) AS name FROM icon_contacts WHERE id = ?",
|
"SELECT CONCAT_WS(' ', title, first_name, last_name) AS name FROM icon_contacts WHERE id = ?",
|
||||||
[data.contact_id]
|
[data.contact_id]
|
||||||
);
|
);
|
||||||
await logHistory({
|
await logHistory({
|
||||||
|
|||||||
+10
-10
@@ -33,11 +33,11 @@ router.get('/', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const [contacts, companies, tenders, projects, leads, history] = await Promise.all([
|
const [contacts, companies, tenders, projects, leads, history] = await Promise.all([
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS title,
|
`SELECT k.id, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS title,
|
||||||
k.position, k.email, k.avatar, k.kind, k.status, co.name AS company
|
k.position, k.email, k.avatar, k.kind, k.status, co.name AS company
|
||||||
FROM icon_contacts k
|
FROM icon_contacts k
|
||||||
LEFT JOIN icon_companies co ON co.id = k.company_id
|
LEFT JOIN icon_companies co ON co.id = k.company_id
|
||||||
WHERE CONCAT_WS(' ', k.first_name, k.last_name) LIKE ? OR k.email LIKE ?
|
WHERE CONCAT_WS(' ', k.title, k.first_name, k.last_name) LIKE ? OR k.email LIKE ?
|
||||||
OR k.phone LIKE ? OR k.position LIKE ? OR k.tags LIKE ? OR k.bio LIKE ?
|
OR k.phone LIKE ? OR k.position LIKE ? OR k.tags LIKE ? OR k.bio LIKE ?
|
||||||
OR k.lead_source LIKE ? OR co.name LIKE ?
|
OR k.lead_source LIKE ? OR co.name LIKE ?
|
||||||
ORDER BY k.last_name LIMIT ${limit}`,
|
ORDER BY k.last_name LIMIT ${limit}`,
|
||||||
@@ -62,7 +62,7 @@ router.get('/', async (req, res, next) => {
|
|||||||
),
|
),
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT p.id, p.title, p.status, p.started_on, p.ended_on,
|
`SELECT p.id, p.title, p.status, p.started_on, p.ended_on,
|
||||||
co.name AS company, CONCAT_WS(' ', k.first_name, k.last_name) AS contact
|
co.name AS company, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact
|
||||||
FROM icon_projects p
|
FROM icon_projects p
|
||||||
LEFT JOIN icon_companies co ON co.id = p.company_id
|
LEFT JOIN icon_companies co ON co.id = p.company_id
|
||||||
LEFT JOIN icon_contacts k ON k.id = p.contact_id
|
LEFT JOIN icon_contacts k ON k.id = p.contact_id
|
||||||
@@ -72,7 +72,7 @@ router.get('/', async (req, res, next) => {
|
|||||||
),
|
),
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT l.id, l.title, l.stage, l.archived, co.name AS company,
|
`SELECT l.id, l.title, l.stage, l.archived, co.name AS company,
|
||||||
CONCAT_WS(' ', k.first_name, k.last_name) AS contact
|
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact
|
||||||
FROM icon_leads l
|
FROM icon_leads l
|
||||||
LEFT JOIN icon_companies co ON co.id = l.company_id
|
LEFT JOIN icon_companies co ON co.id = l.company_id
|
||||||
LEFT JOIN icon_contacts k ON k.id = l.contact_id
|
LEFT JOIN icon_contacts k ON k.id = l.contact_id
|
||||||
@@ -159,11 +159,11 @@ router.get('/mentions', async (req, res, next) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [contacts] = await pool.query(
|
const [contacts] = await pool.query(
|
||||||
`SELECT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS label,
|
`SELECT k.id, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS label,
|
||||||
k.avatar, co.name AS hint
|
k.avatar, co.name AS hint
|
||||||
FROM icon_contacts k
|
FROM icon_contacts k
|
||||||
LEFT JOIN icon_companies co ON co.id = k.company_id
|
LEFT JOIN icon_companies co ON co.id = k.company_id
|
||||||
${q ? "WHERE CONCAT_WS(' ', k.first_name, k.last_name) LIKE ?" : ''}
|
${q ? "WHERE CONCAT_WS(' ', k.title, k.first_name, k.last_name) LIKE ?" : ''}
|
||||||
ORDER BY k.last_name LIMIT 20`,
|
ORDER BY k.last_name LIMIT 20`,
|
||||||
q ? [like] : []
|
q ? [like] : []
|
||||||
);
|
);
|
||||||
@@ -227,7 +227,7 @@ router.post('/ai', async (req, res, next) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [contacts] = await pool.query(
|
const [contacts] = await pool.query(
|
||||||
`SELECT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS name, k.position, k.kind, k.status,
|
`SELECT k.id, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS name, k.position, k.kind, k.status,
|
||||||
k.email, k.phone, k.lead_source, k.tags, k.freelancers, k.contact_state,
|
k.email, k.phone, k.lead_source, k.tags, k.freelancers, k.contact_state,
|
||||||
k.rate_onsite, k.rate_remote, k.linkedin, co.name AS company,
|
k.rate_onsite, k.rate_remote, k.linkedin, co.name AS company,
|
||||||
co.industry AS company_industry
|
co.industry AS company_industry
|
||||||
@@ -241,7 +241,7 @@ router.post('/ai', async (req, res, next) => {
|
|||||||
FROM icon_companies ORDER BY name LIMIT ${AI_LIMIT}`
|
FROM icon_companies ORDER BY name LIMIT ${AI_LIMIT}`
|
||||||
);
|
);
|
||||||
const [former] = await pool.query(
|
const [former] = await pool.query(
|
||||||
`SELECT CONCAT_WS(' ', k.first_name, k.last_name) AS contact, co.name AS company,
|
`SELECT CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact, co.name AS company,
|
||||||
e.position, e.ended_on
|
e.position, e.ended_on
|
||||||
FROM icon_employments e
|
FROM icon_employments e
|
||||||
JOIN icon_contacts k ON k.id = e.contact_id
|
JOIN icon_contacts k ON k.id = e.contact_id
|
||||||
@@ -255,7 +255,7 @@ router.post('/ai', async (req, res, next) => {
|
|||||||
);
|
);
|
||||||
const [projects] = await pool.query(
|
const [projects] = await pool.query(
|
||||||
`SELECT p.id, p.title, p.status, p.started_on, p.ended_on, co.name AS company,
|
`SELECT p.id, p.title, p.status, p.started_on, p.ended_on, co.name AS company,
|
||||||
CONCAT_WS(' ', k.first_name, k.last_name) AS contact
|
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact
|
||||||
FROM icon_projects p
|
FROM icon_projects p
|
||||||
LEFT JOIN icon_companies co ON co.id = p.company_id
|
LEFT JOIN icon_companies co ON co.id = p.company_id
|
||||||
LEFT JOIN icon_contacts k ON k.id = p.contact_id
|
LEFT JOIN icon_contacts k ON k.id = p.contact_id
|
||||||
@@ -263,7 +263,7 @@ router.post('/ai', async (req, res, next) => {
|
|||||||
);
|
);
|
||||||
const [leads] = await pool.query(
|
const [leads] = await pool.query(
|
||||||
`SELECT l.id, l.title, l.stage, l.archived, co.name AS company,
|
`SELECT l.id, l.title, l.stage, l.archived, co.name AS company,
|
||||||
CONCAT_WS(' ', k.first_name, k.last_name) AS contact
|
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact
|
||||||
FROM icon_leads l
|
FROM icon_leads l
|
||||||
LEFT JOIN icon_companies co ON co.id = l.company_id
|
LEFT JOIN icon_companies co ON co.id = l.company_id
|
||||||
LEFT JOIN icon_contacts k ON k.id = l.contact_id
|
LEFT JOIN icon_contacts k ON k.id = l.contact_id
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ router.get('/', async (req, res, next) => {
|
|||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
const [links] = await pool.query(
|
const [links] = await pool.query(
|
||||||
`SELECT tc.tender_id, k.id, k.avatar, k.position, k.status,
|
`SELECT tc.tender_id, k.id, k.avatar, k.position, k.status,
|
||||||
CONCAT_WS(' ', k.first_name, k.last_name) AS name
|
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS name
|
||||||
FROM icon_tender_contacts tc
|
FROM icon_tender_contacts tc
|
||||||
JOIN icon_contacts k ON k.id = tc.contact_id
|
JOIN icon_contacts k ON k.id = tc.contact_id
|
||||||
WHERE tc.tender_id IN (?)
|
WHERE tc.tender_id IN (?)
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ CREATE TABLE IF NOT EXISTS icon_companies (
|
|||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS icon_contacts (
|
CREATE TABLE IF NOT EXISTS icon_contacts (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
-- Akademischer Titel als Freitext ("Dr.", "Prof. Dr."). Bewusst keine
|
||||||
|
-- Auswahlliste: die Schreibweisen sind zu vielfaeltig, um sie zu pflegen.
|
||||||
|
title VARCHAR(60) NULL,
|
||||||
first_name VARCHAR(120) NULL,
|
first_name VARCHAR(120) NULL,
|
||||||
last_name VARCHAR(120) NOT NULL,
|
last_name VARCHAR(120) NOT NULL,
|
||||||
position VARCHAR(190) NULL,
|
position VARCHAR(190) NULL,
|
||||||
@@ -94,7 +97,7 @@ CREATE TABLE IF NOT EXISTS icon_contacts (
|
|||||||
-- Aktueller Arbeitgeber. Die Historie der frueheren steht in
|
-- Aktueller Arbeitgeber. Die Historie der frueheren steht in
|
||||||
-- icon_employments - daraus speist sich der Zweig "Verlassen".
|
-- icon_employments - daraus speist sich der Zweig "Verlassen".
|
||||||
company_id INT UNSIGNED NULL,
|
company_id INT UNSIGNED NULL,
|
||||||
kind ENUM('Experte','Ansprechpartner','Lead') NOT NULL DEFAULT 'Experte',
|
kind ENUM('Experte','Ansprechpartner (Kunde)','Lead') NOT NULL DEFAULT 'Experte',
|
||||||
status ENUM('Interessiert','Interview','Gebucht','Inaktiv') NOT NULL DEFAULT 'Interessiert',
|
status ENUM('Interessiert','Interview','Gebucht','Inaktiv') NOT NULL DEFAULT 'Interessiert',
|
||||||
-- Woher kommt der Kontakt (LinkedIn, Empfehlung, Portal, ...).
|
-- Woher kommt der Kontakt (LinkedIn, Empfehlung, Portal, ...).
|
||||||
lead_source VARCHAR(190) NULL,
|
lead_source VARCHAR(190) NULL,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import type { Company, DirectoryUser } from '../../types';
|
import type { Company, DirectoryUser } from '../../types';
|
||||||
import { companiesApi } from '../../lib/api';
|
import { companiesApi } from '../../lib/api';
|
||||||
import { useOptions } from '../../lib/options';
|
import { useOptions, sortByLabel } from '../../lib/options';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { StarRating } from '../ui/StarRating';
|
import { StarRating } from '../ui/StarRating';
|
||||||
import { Pill, PrimaryButton, Segmented, inputClass } from '../ui/form';
|
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`}
|
className={`${inputClass} w-auto`}
|
||||||
>
|
>
|
||||||
<option value="">Alle Branchen</option>
|
<option value="">Alle Branchen</option>
|
||||||
{/* Gepflegte Liste zuerst, dazu was im Bestand sonst noch vorkommt. */}
|
{/* Gepflegte Liste, dazu was im Bestand sonst noch vorkommt - alphabetisch. */}
|
||||||
{[...new Set([...industryOptions, ...industries])].map((value) => (
|
{sortByLabel([...new Set([...industryOptions, ...industries])]).map((value) => (
|
||||||
<option key={value}>{value}</option>
|
<option key={value}>{value}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { HistoryFeed } from '../history/HistoryFeed';
|
|||||||
import { TRISTATE_LABELS } from '../contacts/contactUi';
|
import { TRISTATE_LABELS } from '../contacts/contactUi';
|
||||||
import { useToast } from '../Toast';
|
import { useToast } from '../Toast';
|
||||||
import { useNavigation } from '../../lib/navigation';
|
import { useNavigation } from '../../lib/navigation';
|
||||||
import { useOptions } from '../../lib/options';
|
import { useOptions, sortByLabel } from '../../lib/options';
|
||||||
import { useAuth } from '../../lib/auth';
|
import { useAuth } from '../../lib/auth';
|
||||||
|
|
||||||
interface CompanyModalProps {
|
interface CompanyModalProps {
|
||||||
@@ -264,7 +264,7 @@ export const CompanyModal: React.FC<CompanyModalProps> = ({
|
|||||||
verschwinden. */}
|
verschwinden. */}
|
||||||
{(industryOptions.includes(form.industry) || !form.industry
|
{(industryOptions.includes(form.industry) || !form.industry
|
||||||
? industryOptions
|
? industryOptions
|
||||||
: [form.industry, ...industryOptions]
|
: sortByLabel([form.industry, ...industryOptions])
|
||||||
).map((industry) => (
|
).map((industry) => (
|
||||||
<option key={industry} value={industry}>
|
<option key={industry} value={industry}>
|
||||||
{industry}
|
{industry}
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import {
|
|||||||
type ContactState,
|
type ContactState,
|
||||||
type DirectoryUser,
|
type DirectoryUser,
|
||||||
type Employment,
|
type Employment,
|
||||||
|
type PerincoContact,
|
||||||
type Tristate,
|
type Tristate,
|
||||||
} from '../../types';
|
} 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 { useOptions } from '../../lib/options';
|
||||||
import { Modal, ConfirmDialog } from '../ui/Modal';
|
import { Modal, ConfirmDialog } from '../ui/Modal';
|
||||||
import { AvatarPicker } from '../ui/AvatarPicker';
|
import { AvatarPicker } from '../ui/AvatarPicker';
|
||||||
@@ -39,6 +42,7 @@ interface ContactModalProps {
|
|||||||
type Tab = 'stamm' | 'werdegang' | 'historie';
|
type Tab = 'stamm' | 'werdegang' | 'historie';
|
||||||
|
|
||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
|
title: '',
|
||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: '',
|
last_name: '',
|
||||||
position: '',
|
position: '',
|
||||||
@@ -82,6 +86,17 @@ export const ContactModal: React.FC<ContactModalProps> = ({
|
|||||||
const [interviewDate, setInterviewDate] = useState('');
|
const [interviewDate, setInterviewDate] = useState('');
|
||||||
const [interviewNote, setInterviewNote] = 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;
|
const isNew = contactId === null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -116,15 +131,95 @@ export const ContactModal: React.FC<ContactModalProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, contactId]);
|
}, [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]) =>
|
const set = <K extends keyof typeof EMPTY>(key: K, value: (typeof EMPTY)[K]) =>
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
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
|
* Nach einer Aenderung am Werdegang neu laden - und zwar nur die Karte, nicht
|
||||||
* das Formular: was der Benutzer oben eingetippt und noch nicht gespeichert
|
* das Formular: was der Benutzer oben eingetippt und noch nicht gespeichert
|
||||||
* hat, darf dabei nicht verschwinden. Ausnahme ist der Arbeitgeber, denn den
|
* hat, darf dabei nicht verschwinden. Ausnahme ist der Arbeitgeber, denn den
|
||||||
* bestimmt die laufende Station.
|
* 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 () => {
|
const reloadDetail = async () => {
|
||||||
if (contactId === null) return;
|
if (contactId === null) return;
|
||||||
const data = await contactsApi.detail(contactId);
|
const data = await contactsApi.detail(contactId);
|
||||||
@@ -133,8 +228,8 @@ export const ContactModal: React.FC<ContactModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fullName = useMemo(
|
const fullName = useMemo(
|
||||||
() => [form.first_name, form.last_name].filter(Boolean).join(' ').trim(),
|
() => [form.title, form.first_name, form.last_name].filter(Boolean).join(' ').trim(),
|
||||||
[form.first_name, form.last_name]
|
[form.title, form.first_name, form.last_name]
|
||||||
);
|
);
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
@@ -216,7 +311,7 @@ export const ContactModal: React.FC<ContactModalProps> = ({
|
|||||||
title={isNew ? 'Neuer Kontakt' : fullName || 'Kontakt'}
|
title={isNew ? 'Neuer Kontakt' : fullName || 'Kontakt'}
|
||||||
subtitle={
|
subtitle={
|
||||||
isNew
|
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]
|
: [form.position, companies.find((c) => c.id === Number(form.company_id))?.name]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')
|
.join(' · ')
|
||||||
@@ -269,6 +364,48 @@ export const ContactModal: React.FC<ContactModalProps> = ({
|
|||||||
|
|
||||||
{(isNew || tab === 'stamm') && (
|
{(isNew || tab === 'stamm') && (
|
||||||
<div className="space-y-5">
|
<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
|
<AvatarPicker
|
||||||
name={fullName || 'Kontakt'}
|
name={fullName || 'Kontakt'}
|
||||||
value={form.avatar}
|
value={form.avatar}
|
||||||
@@ -277,6 +414,13 @@ export const ContactModal: React.FC<ContactModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<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">
|
<Field label="Vorname">
|
||||||
<input
|
<input
|
||||||
value={form.first_name}
|
value={form.first_name}
|
||||||
@@ -585,6 +729,21 @@ export const ContactModal: React.FC<ContactModalProps> = ({
|
|||||||
onConfirm={remove}
|
onConfirm={remove}
|
||||||
onCancel={() => setConfirmDelete(false)}
|
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) {
|
function toForm(contact: Partial<Contact> | undefined) {
|
||||||
if (!contact) return {};
|
if (!contact) return {};
|
||||||
return {
|
return {
|
||||||
|
title: contact.title ?? '',
|
||||||
first_name: contact.first_name ?? '',
|
first_name: contact.first_name ?? '',
|
||||||
last_name: contact.last_name ?? '',
|
last_name: contact.last_name ?? '',
|
||||||
position: contact.position ?? '',
|
position: contact.position ?? '',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { contactsApi, perincoApi } from '../../lib/api';
|
import { contactsApi, perincoApi } from '../../lib/api';
|
||||||
import { useOptions } from '../../lib/options';
|
import { useOptions } from '../../lib/options';
|
||||||
|
import { buildMatchIndex } from '../../lib/contactMatch';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { Pill, PrimaryButton, Segmented, inputClass } from '../ui/form';
|
import { Pill, PrimaryButton, Segmented, inputClass } from '../ui/form';
|
||||||
import { ContactModal } from './ContactModal';
|
import { ContactModal } from './ContactModal';
|
||||||
@@ -174,9 +175,22 @@ export const Contacts: React.FC<ContactsProps> = ({
|
|||||||
};
|
};
|
||||||
}, [perincoOn]);
|
}, [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 visiblePerinco = useMemo(() => {
|
||||||
const q = term.trim().toLowerCase();
|
const q = term.trim().toLowerCase();
|
||||||
return perinco.filter((contact) => {
|
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 -
|
// Die Arten drüben (Recruiter, Partner, Endkunde) sind andere als hier -
|
||||||
// der Artenfilter darf sie deshalb nicht wegfiltern.
|
// der Artenfilter darf sie deshalb nicht wegfiltern.
|
||||||
if (kind && contact.kind !== kind) return false;
|
if (kind && contact.kind !== kind) return false;
|
||||||
@@ -188,7 +202,7 @@ export const Contacts: React.FC<ContactsProps> = ({
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(q);
|
.includes(q);
|
||||||
});
|
});
|
||||||
}, [perinco, term, kind, status]);
|
}, [perinco, term, kind, status, matches]);
|
||||||
|
|
||||||
// Gefiltert wird im Browser: die Liste ist klein genug, und so bleibt das
|
// Gefiltert wird im Browser: die Liste ist klein genug, und so bleibt das
|
||||||
// Tippen ohne Wartezeit.
|
// Tippen ohne Wartezeit.
|
||||||
@@ -357,6 +371,7 @@ export const Contacts: React.FC<ContactsProps> = ({
|
|||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<Pill tone={KIND_TONE[contact.kind]}>{contact.kind}</Pill>
|
<Pill tone={KIND_TONE[contact.kind]}>{contact.kind}</Pill>
|
||||||
<Pill tone={STATUS_TONE[contact.status]}>{contact.status}</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.lead_source && <Pill>{contact.lead_source}</Pill>}
|
||||||
{(contact.interview_count ?? 0) > 0 && (
|
{(contact.interview_count ?? 0) > 0 && (
|
||||||
<Pill tone="violet">{contact.interview_count} Interview(s)</Pill>
|
<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">
|
<span className="flex items-center gap-2">
|
||||||
<Avatar name={contact.name} src={contact.avatar} size={28} />
|
<Avatar name={contact.name} src={contact.avatar} size={28} />
|
||||||
<span className="font-medium text-ink-900">{contact.name}</span>
|
<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>
|
</span>
|
||||||
</td>
|
</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.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> = {
|
export const KIND_TONE: Record<ContactKind, string> = {
|
||||||
Experte: 'brand',
|
Experte: 'brand',
|
||||||
|
'Ansprechpartner (Kunde)': 'violet',
|
||||||
|
// Altbestand, der die Umbenennung nicht mitgemacht hat - sonst bliebe die
|
||||||
|
// Kachel farblos.
|
||||||
Ansprechpartner: 'violet',
|
Ansprechpartner: 'violet',
|
||||||
Lead: 'sky',
|
Lead: 'sky',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ import { RichTextEditor } from '../ui/RichTextEditor';
|
|||||||
import { DictateButton, appendParagraph } from '../ui/Dictate';
|
import { DictateButton, appendParagraph } from '../ui/Dictate';
|
||||||
import { toEditorHtml } from '../../lib/richText';
|
import { toEditorHtml } from '../../lib/richText';
|
||||||
import { useToast } from '../Toast';
|
import { useToast } from '../Toast';
|
||||||
|
import {
|
||||||
|
BASIS_LABEL,
|
||||||
|
HOURS_PER_DAY,
|
||||||
|
formatEuro,
|
||||||
|
formatMarginAmount,
|
||||||
|
formatMarginPercent,
|
||||||
|
marginTone,
|
||||||
|
projectMargin,
|
||||||
|
} from './margin';
|
||||||
|
|
||||||
interface ProjectsProps {
|
interface ProjectsProps {
|
||||||
companies: Company[];
|
companies: Company[];
|
||||||
@@ -126,10 +135,13 @@ export const Projects: React.FC<ProjectsProps> = ({ companies, onChanged, openId
|
|||||||
{project.contact_name}
|
{project.contact_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<p className="mt-auto pt-1 text-xs text-ink-400">
|
<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'}
|
{formatDate(project.started_on)} – {project.ended_on ? formatDate(project.ended_on) : 'offen'}
|
||||||
{project.rate ? ` · ${project.rate.toLocaleString('de-DE')} €` : ''}
|
{project.rate ? ` · ${project.rate.toLocaleString('de-DE')} €/Tag` : ''}
|
||||||
</p>
|
</p>
|
||||||
|
<MarginLine project={project} />
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</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 = {
|
const EMPTY = {
|
||||||
title: '',
|
title: '',
|
||||||
company_id: '' as number | '',
|
company_id: '' as number | '',
|
||||||
@@ -189,6 +280,12 @@ const ProjectModal: React.FC<{
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = 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]) =>
|
const set = <K extends keyof typeof EMPTY>(key: K, value: (typeof EMPTY)[K]) =>
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
|
|
||||||
@@ -328,7 +425,7 @@ const ProjectModal: React.FC<{
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Tagessatz">
|
<Field label="Tagessatz (€/Tag)" hint={`Was ICON dem Kunden berechnet - ${HOURS_PER_DAY} h je Tag.`}>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={form.rate}
|
value={form.rate}
|
||||||
@@ -338,6 +435,8 @@ const ProjectModal: React.FC<{
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MarginPanel rate={form.rate} contact={selectedContact} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>Notizen</label>
|
<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 [testing, setTesting] = useState(false);
|
||||||
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
|
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
const [askRemove, setAskRemove] = useState(false);
|
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 () => {
|
const loadConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await aiApi.config();
|
const data = await aiApi.config();
|
||||||
setConfig(data);
|
setConfig(data);
|
||||||
setModel(data.model);
|
setModel(data.model);
|
||||||
|
if (data.configured) void loadModels();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
|
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
|
||||||
}
|
}
|
||||||
@@ -53,6 +68,11 @@ export const AiSettings: React.FC = () => {
|
|||||||
setConfig(data);
|
setConfig(data);
|
||||||
setModel(data.model);
|
setModel(data.model);
|
||||||
setKey('');
|
setKey('');
|
||||||
|
// Ein anderer Schlüssel darf andere Modelle nutzen.
|
||||||
|
if (changes.key !== undefined) {
|
||||||
|
if (data.configured) void loadModels();
|
||||||
|
else setModels(null);
|
||||||
|
}
|
||||||
toast('Gespeichert.', 'success');
|
toast('Gespeichert.', 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
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>
|
<h3 className="mb-4 text-sm font-semibold text-ink-900">Modell</h3>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Modellname"
|
label="Modell"
|
||||||
hint={`Vorgabe ist ${config.defaultModel}. Achtung: gpt-5.6-luna erlaubt keinen eigenen „temperature"-Wert – das Backend schickt deshalb keinen.`}
|
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.`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
{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
|
<input
|
||||||
value={model}
|
value={model}
|
||||||
onChange={(event) => setModel(event.target.value)}
|
onChange={(event) => setModel(event.target.value)}
|
||||||
placeholder={config.defaultModel}
|
placeholder={config.configured && !modelsError ? 'Modelle werden geladen …' : config.defaultModel}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className={`${inputClass} font-mono`}
|
className={`${inputClass} font-mono`}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Field>
|
</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">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { fetchModelSize, formatSize } from '../../lib/whisperModels';
|
|||||||
*
|
*
|
||||||
* Einzustellen gibt es hier bewusst nichts: Modell, Genauigkeit, Rechenweg und
|
* Einzustellen gibt es hier bewusst nichts: Modell, Genauigkeit, Rechenweg und
|
||||||
* Sprache stehen fest (siehe lib/whisper.tsx). Übrig bleibt, was wirklich
|
* Sprache stehen fest (siehe lib/whisper.tsx). Übrig bleibt, was wirklich
|
||||||
* gebraucht wird - einmal herunterladen, je Sitzung einmal aktivieren. Und der
|
* gebraucht wird - einmal herunterladen, danach laeuft es. Und der Weg, es
|
||||||
* Weg, es wieder loszuwerden.
|
* wieder loszuwerden.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const WhisperSettings: React.FC = () => {
|
export const WhisperSettings: React.FC = () => {
|
||||||
@@ -79,10 +79,9 @@ export const WhisperSettings: React.FC = () => {
|
|||||||
Dienstleister.
|
Dienstleister.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 max-w-3xl text-sm text-ink-500">
|
<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
|
Einmal herunterladen genügt: das Modell bleibt in diesem Browser und steht danach bei
|
||||||
wird es bei jeder Anmeldung mit einem Klick – das geschieht bewusst nicht von selbst:
|
jeder Anmeldung von selbst bereit. Auf einem anderen Gerät oder in einem anderen Browser
|
||||||
das Einrichten der Erkennung beansprucht für einige Sekunden die Grafikkarte, und das
|
fällt der Download erneut an.
|
||||||
soll niemanden mitten in der Arbeit treffen.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -109,7 +108,7 @@ export const WhisperSettings: React.FC = () => {
|
|||||||
? 'Gleich fertig.'
|
? 'Gleich fertig.'
|
||||||
: progressLabel || 'Download startet …'
|
: progressLabel || 'Download startet …'
|
||||||
: isCached
|
: 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.`}
|
: `Einmalig werden ${sizeLabel ?? 'die Modelldateien'} geladen – danach bleibt es in diesem Browser.`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ export const aiApi = {
|
|||||||
text: (task: AiTask, current: string, prompt?: string) =>
|
text: (task: AiTask, current: string, prompt?: string) =>
|
||||||
post<{ text: string; model: string }>('/ai/text', { task, current, prompt }),
|
post<{ text: string; model: string }>('/ai/text', { task, current, prompt }),
|
||||||
config: () => get<AiConfig>('/ai/config'),
|
config: () => get<AiConfig>('/ai/config'),
|
||||||
|
models: () => get<{ models: { id: string; created: number }[] }>('/ai/models'),
|
||||||
saveConfig: (changes: { key?: string; model?: string }) => patch<AiConfig>('/ai/config', changes),
|
saveConfig: (changes: { key?: string; model?: string }) => patch<AiConfig>('/ai/config', changes),
|
||||||
test: () => post<{ ok: true; model: string; ms: number; answer: string }>('/ai/test'),
|
test: () => post<{ ok: true; model: string; ms: number; answer: string }>('/ai/test'),
|
||||||
};
|
};
|
||||||
@@ -315,6 +316,24 @@ export const aiApi = {
|
|||||||
export const perincoApi = {
|
export const perincoApi = {
|
||||||
status: () => get<{ configured: boolean; url: string | null }>('/perinco/status'),
|
status: () => get<{ configured: boolean; url: string | null }>('/perinco/status'),
|
||||||
contacts: () => get<{ contacts: PerincoContact[]; cached: boolean }>('/perinco/contacts'),
|
contacts: () => get<{ contacts: PerincoContact[]; cached: boolean }>('/perinco/contacts'),
|
||||||
|
/**
|
||||||
|
* Abgleich eines doppelt gefuehrten Kontakts.
|
||||||
|
*
|
||||||
|
* `local` sind die Felder, die hier gelten sollen, `remote` die, die drueben
|
||||||
|
* gelten sollen - beide in der Sprache der Bruecke (`notes`, `source`).
|
||||||
|
*/
|
||||||
|
merge: (
|
||||||
|
remoteId: string,
|
||||||
|
body: {
|
||||||
|
contact_id: number;
|
||||||
|
local: Record<string, unknown>;
|
||||||
|
remote: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
) =>
|
||||||
|
post<{ ok: true; local_changed: string[]; remote_changed: string[] }>(
|
||||||
|
`/perinco/contacts/${remoteId}/merge`,
|
||||||
|
body
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const historyApi = {
|
export const historyApi = {
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import type { Contact, PerincoContact } from '../types';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wann ist ein Kontakt hier und ein Kontakt drueben derselbe Mensch?
|
||||||
|
*
|
||||||
|
* Die Frage entscheidet zweierlei: ob Gunther Stammwitz in der Liste einmal
|
||||||
|
* oder zweimal steht, und was der Abgleich anfasst. Sie wird deshalb an genau
|
||||||
|
* einer Stelle beantwortet - im perinco CRM steht dieselbe Regel noch einmal,
|
||||||
|
* und beide muessen gleich urteilen.
|
||||||
|
*
|
||||||
|
* Die Regel:
|
||||||
|
* 1. Haben beide eine E-Mail, entscheidet allein die E-Mail. Gleicher Name
|
||||||
|
* bei verschiedenen Adressen sind zwei Personen - Namensvettern gibt es.
|
||||||
|
* 2. Fehlt auf mindestens einer Seite die E-Mail, zaehlt der Name.
|
||||||
|
* Damit wird niemand faelschlich zusammengelegt, nur weil er haeufig heisst.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const norm = (value: string | null | undefined): string =>
|
||||||
|
String(value ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
interface Person {
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSamePerson(a: Person, b: Person): boolean {
|
||||||
|
const emailA = norm(a.email);
|
||||||
|
const emailB = norm(b.email);
|
||||||
|
if (emailA && emailB) return emailA === emailB;
|
||||||
|
|
||||||
|
const lastA = norm(a.last_name);
|
||||||
|
const lastB = norm(b.last_name);
|
||||||
|
if (!lastA || lastA !== lastB) return false;
|
||||||
|
|
||||||
|
// Ohne Vornamen auf einer Seite reicht der Nachname nicht - "Schmidt" ist
|
||||||
|
// kein Beleg.
|
||||||
|
const firstA = norm(a.first_name);
|
||||||
|
const firstB = norm(b.first_name);
|
||||||
|
if (!firstA || !firstB) return false;
|
||||||
|
return firstA === firstB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zuordnung in beide Richtungen, damit Liste und Karte sie nachschlagen koennen. */
|
||||||
|
export interface MatchIndex {
|
||||||
|
/** perinco-Kennung ("perinco-31") -> Kontakt hier. */
|
||||||
|
byRemote: Map<string, Contact>;
|
||||||
|
/** Kontaktkennung hier -> Kontakt drueben. */
|
||||||
|
byLocal: Map<number, PerincoContact>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMatchIndex(contacts: Contact[], remote: PerincoContact[]): MatchIndex {
|
||||||
|
const byRemote = new Map<string, Contact>();
|
||||||
|
const byLocal = new Map<number, PerincoContact>();
|
||||||
|
|
||||||
|
for (const other of remote) {
|
||||||
|
// Der erste Treffer gewinnt. Mehrdeutigkeit gibt es nur bei doppelt
|
||||||
|
// gefuehrten Kontakten innerhalb eines Hauses - das ist ein Problem dort
|
||||||
|
// und nicht eines, das der Abgleich loesen kann.
|
||||||
|
const hit = contacts.find((contact) => !byLocal.has(contact.id) && isSamePerson(contact, other));
|
||||||
|
if (!hit) continue;
|
||||||
|
byRemote.set(other.id, hit);
|
||||||
|
byLocal.set(hit.id, other);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { byRemote, byLocal };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die abgleichbaren Felder.
|
||||||
|
*
|
||||||
|
* Es sind genau die, die die Bruecke ueberträgt. Die Art des Kontakts fehlt mit
|
||||||
|
* Absicht: hier Experte / Ansprechpartner (Kunde) / Lead, drueben Recruiter /
|
||||||
|
* Partner / Endkunde - das sind keine zwei Schreibweisen derselben Sache. Die
|
||||||
|
* Firma fehlt ebenfalls, sie ist hier ein Verweis und drueben ein Textfeld.
|
||||||
|
*/
|
||||||
|
export type MergeKey =
|
||||||
|
| 'first_name'
|
||||||
|
| 'last_name'
|
||||||
|
| 'position'
|
||||||
|
| 'email'
|
||||||
|
| 'phone'
|
||||||
|
| 'linkedin'
|
||||||
|
| 'lead_source'
|
||||||
|
| 'bio'
|
||||||
|
| 'tags'
|
||||||
|
| 'avatar';
|
||||||
|
|
||||||
|
export interface MergeField {
|
||||||
|
key: MergeKey;
|
||||||
|
label: string;
|
||||||
|
/** Feldname auf der Bruecke - dort heissen zwei Felder anders. */
|
||||||
|
bridge: string;
|
||||||
|
kind: 'text' | 'tags' | 'image';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MERGE_FIELDS: MergeField[] = [
|
||||||
|
{ key: 'first_name', label: 'Vorname', bridge: 'first_name', kind: 'text' },
|
||||||
|
{ key: 'last_name', label: 'Nachname', bridge: 'last_name', kind: 'text' },
|
||||||
|
{ key: 'position', label: 'Position', bridge: 'position', kind: 'text' },
|
||||||
|
{ key: 'email', label: 'E-Mail', bridge: 'email', kind: 'text' },
|
||||||
|
{ key: 'phone', label: 'Telefon', bridge: 'phone', kind: 'text' },
|
||||||
|
{ key: 'linkedin', label: 'LinkedIn', bridge: 'linkedin', kind: 'text' },
|
||||||
|
{ key: 'lead_source', label: 'Quelle', bridge: 'source', kind: 'text' },
|
||||||
|
{ key: 'bio', label: 'Notiz', bridge: 'notes', kind: 'text' },
|
||||||
|
{ key: 'tags', label: 'Schlagworte', bridge: 'tags', kind: 'tags' },
|
||||||
|
{ key: 'avatar', label: 'Foto', bridge: 'avatar', kind: 'image' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type DiffState = 'equal' | 'only-local' | 'only-remote' | 'conflict';
|
||||||
|
|
||||||
|
export interface FieldDiff {
|
||||||
|
field: MergeField;
|
||||||
|
local: string | string[] | null;
|
||||||
|
remote: string | string[] | null;
|
||||||
|
state: DiffState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmpty = (value: string | string[] | null | undefined): boolean =>
|
||||||
|
Array.isArray(value) ? value.length === 0 : !String(value ?? '').trim();
|
||||||
|
|
||||||
|
const sameValue = (a: unknown, b: unknown): boolean => {
|
||||||
|
if (Array.isArray(a) || Array.isArray(b)) {
|
||||||
|
const listA = [...((a as string[]) ?? [])].map((t) => t.trim().toLowerCase()).sort();
|
||||||
|
const listB = [...((b as string[]) ?? [])].map((t) => t.trim().toLowerCase()).sort();
|
||||||
|
return listA.length === listB.length && listA.every((value, index) => value === listB[index]);
|
||||||
|
}
|
||||||
|
return String(a ?? '').trim() === String(b ?? '').trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feld fuer Feld: gleich, fehlt hier, fehlt drueben, oder beide gesetzt und
|
||||||
|
* verschieden.
|
||||||
|
*
|
||||||
|
* Die Felder heissen auf beiden Seiten gleich, weil `routes/perinco.js` den
|
||||||
|
* Kontakt von drueben schon in die hiesige Form bringt.
|
||||||
|
*/
|
||||||
|
export function diffContacts(local: Contact, remote: PerincoContact): FieldDiff[] {
|
||||||
|
return MERGE_FIELDS.map((field) => {
|
||||||
|
const here = (local as unknown as Record<string, string | string[] | null>)[field.key] ?? null;
|
||||||
|
const there = (remote as unknown as Record<string, string | string[] | null>)[field.key] ?? null;
|
||||||
|
|
||||||
|
let state: DiffState;
|
||||||
|
if (sameValue(here, there)) state = 'equal';
|
||||||
|
else if (isEmpty(here)) state = 'only-remote';
|
||||||
|
else if (isEmpty(there)) state = 'only-local';
|
||||||
|
else state = 'conflict';
|
||||||
|
|
||||||
|
return { field, local: here, remote: there, state };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt es ueberhaupt etwas abzugleichen? Steuert den Knopf in der Liste. */
|
||||||
|
export function hasOpenDiff(diffs: FieldDiff[]): boolean {
|
||||||
|
return diffs.some((entry) => entry.state !== 'equal');
|
||||||
|
}
|
||||||
+8
-1
@@ -35,7 +35,9 @@ export const OptionsProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
const values = (field: string) =>
|
const values = (field: string) =>
|
||||||
fields.find((entry) => entry.field === field)?.values.map((value) => value.value) ?? [];
|
fields.find((entry) => entry.field === field)?.values.map((value) => value.value) ?? [];
|
||||||
|
|
||||||
setIndustries(values('industry'));
|
// Branchen alphabetisch: die Liste wird lang, und in einem Auswahlfeld
|
||||||
|
// sucht man einen Namen - nicht eine gepflegte Reihenfolge.
|
||||||
|
setIndustries(sortByLabel(values('industry')));
|
||||||
const kinds = values('contact_kind');
|
const kinds = values('contact_kind');
|
||||||
setContactKinds(kinds.length > 0 ? kinds : DEFAULT_CONTACT_KINDS);
|
setContactKinds(kinds.length > 0 ? kinds : DEFAULT_CONTACT_KINDS);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -55,6 +57,11 @@ export const OptionsProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
return <OptionsContext.Provider value={value}>{children}</OptionsContext.Provider>;
|
return <OptionsContext.Provider value={value}>{children}</OptionsContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Alphabetisch nach deutschen Regeln - "Ö" gehoert zu "O", nicht ans Ende. */
|
||||||
|
export function sortByLabel(values: string[]): string[] {
|
||||||
|
return [...values].sort((a, b) => a.localeCompare(b, 'de', { sensitivity: 'base' }));
|
||||||
|
}
|
||||||
|
|
||||||
export function useOptions(): OptionsValue {
|
export function useOptions(): OptionsValue {
|
||||||
const ctx = useContext(OptionsContext);
|
const ctx = useContext(OptionsContext);
|
||||||
if (!ctx) throw new Error('useOptions muss innerhalb von <OptionsProvider> verwendet werden.');
|
if (!ctx) throw new Error('useOptions muss innerhalb von <OptionsProvider> verwendet werden.');
|
||||||
|
|||||||
+21
-9
@@ -173,6 +173,8 @@ export const WhisperProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
new Map<number, { resolve: (value: { text: string; ms: number }) => void; reject: (err: Error) => void }>()
|
new Map<number, { resolve: (value: { text: string; ms: number }) => void; reject: (err: Error) => void }>()
|
||||||
);
|
);
|
||||||
const nextJobId = useRef(1);
|
const nextJobId = useRef(1);
|
||||||
|
/** Verhindert, dass nach Loeschen oder Entladen sofort neu geladen wird. */
|
||||||
|
const autoLoadTriedRef = useRef(false);
|
||||||
|
|
||||||
// Details zur GPU nachladen - das geht nur asynchron.
|
// Details zur GPU nachladen - das geht nur asynchron.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -420,18 +422,28 @@ export const WhisperProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
[status, ensureWorker]
|
[status, ensureWorker]
|
||||||
);
|
);
|
||||||
|
|
||||||
/*
|
/**
|
||||||
* Das Modell wird NICHT von selbst bereitgestellt, auch nicht aus dem Cache.
|
* Liegt das Modell im Browser, wird es beim Anmelden von selbst bereitgestellt
|
||||||
|
* - einmal herunterladen genuegt also. Ohne Cache passiert nichts: ein
|
||||||
|
* Download von mehreren hundert Megabyte soll nie unaufgefordert starten.
|
||||||
*
|
*
|
||||||
* Es hat sich anders gezeigt, als es gedacht war: das Anlegen der
|
* Das Anlegen der ONNX-Sitzung kostet ein paar Sekunden Grafikkarte. Das ist
|
||||||
* ONNX-Sitzung uebersetzt hunderte Shader und legt dabei Zeiger und
|
* der Preis dafuer, dass das Diktat nach jeder Anmeldung einfach da ist -
|
||||||
* Darstellung fuer eine Weile still. Wer das CRM oeffnet, um einen Lead zu
|
* statt in jeder Sitzung erst wieder eingeschaltet werden zu muessen.
|
||||||
* verschieben, haelt das - zu Recht - fuer einen Absturz der Anwendung.
|
|
||||||
*
|
*
|
||||||
* Deshalb: ein Klick, und zwar der des Benutzers. Liegt das Modell im Cache,
|
* Bewusst nur ein einziges Mal pro Seitenaufruf: wer das Modell absichtlich
|
||||||
* kostet "Diktat aktivieren" nur noch das Anlegen der Sitzung; der Download
|
* loescht oder aus dem Speicher nimmt, will es nicht sofort wieder geladen
|
||||||
* faellt nur einmal je Browser an.
|
* bekommen.
|
||||||
*/
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoLoadTriedRef.current) return;
|
||||||
|
if (!isCached || status !== 'idle') return;
|
||||||
|
autoLoadTriedRef.current = true;
|
||||||
|
load().catch(() => {
|
||||||
|
/* Fehler steht im Status */
|
||||||
|
});
|
||||||
|
}, [isCached, status, load]);
|
||||||
|
|
||||||
useEffect(() => () => workerRef.current?.terminate(), []);
|
useEffect(() => () => workerRef.current?.terminate(), []);
|
||||||
|
|
||||||
const value = useMemo<WhisperContextValue>(
|
const value = useMemo<WhisperContextValue>(
|
||||||
|
|||||||
+8
-2
@@ -56,7 +56,7 @@ export interface DirectoryUser {
|
|||||||
* solange die Liste noch nicht geladen ist.
|
* solange die Liste noch nicht geladen ist.
|
||||||
*/
|
*/
|
||||||
export type ContactKind = string;
|
export type ContactKind = string;
|
||||||
export const DEFAULT_CONTACT_KINDS = ['Experte', 'Ansprechpartner', 'Lead'];
|
export const DEFAULT_CONTACT_KINDS = ['Experte', 'Ansprechpartner (Kunde)', 'Lead'];
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Der frueher vorhandene Verfuegbarkeitsstatus ist entfallen (Featureliste vom
|
* Der frueher vorhandene Verfuegbarkeitsstatus ist entfallen (Featureliste vom
|
||||||
@@ -76,9 +76,11 @@ export const CONTACT_STATE_LABELS: Record<ContactState, string> = {
|
|||||||
|
|
||||||
export interface Contact {
|
export interface Contact {
|
||||||
id: number;
|
id: number;
|
||||||
|
/** Akademischer Titel als Freitext ("Dr.", "Prof. Dr."). */
|
||||||
|
title: string | null;
|
||||||
first_name: string | null;
|
first_name: string | null;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
/** Vor- und Nachname, vom Backend zusammengesetzt. */
|
/** Titel, Vor- und Nachname, vom Backend zusammengesetzt. */
|
||||||
name: string;
|
name: string;
|
||||||
position: string | null;
|
position: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -271,9 +273,13 @@ export interface Project {
|
|||||||
contact_id: number | null;
|
contact_id: number | null;
|
||||||
contact_name?: string | null;
|
contact_name?: string | null;
|
||||||
contact_avatar?: string | null;
|
contact_avatar?: string | null;
|
||||||
|
/** Stundensaetze des Beraters - die Liste liefert sie mit, siehe `projectMargin`. */
|
||||||
|
contact_rate_onsite?: number | null;
|
||||||
|
contact_rate_remote?: number | null;
|
||||||
started_on: string | null;
|
started_on: string | null;
|
||||||
ended_on: string | null;
|
ended_on: string | null;
|
||||||
status: 'Aktiv' | 'Nicht aktiv' | 'Abgeschlossen';
|
status: 'Aktiv' | 'Nicht aktiv' | 'Abgeschlossen';
|
||||||
|
/** Tagessatz, den ICON dem Kunden berechnet (in Euro). */
|
||||||
rate: number | null;
|
rate: number | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user