diff --git a/Featureliste.txt b/Featureliste.txt index 867ea98..3634b5b 100644 --- a/Featureliste.txt +++ b/Featureliste.txt @@ -1,6 +1,6 @@ 14.09.2026 -Was muss geändert werden: +Was muss geändert werden am ICON CRM: - Verfügbarkeitsstatus entfernen aus den Kontakten - Können wir eine Liste automatisiert erstellen, der letzte Arbeitgeber (Branche & Unternehmen) diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index 2b2e704..1ff937e 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -152,6 +152,24 @@ Projekt). (Aktiv / Nicht aktiv / Abgeschlossen), `rate`, `notes` (HTML). Im Menü heißt die 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 CRM (`KUNDE:`, `KONTAKT:`, `EXPERTE:`, `START:`, `ENDE:`), darunter der übernommene Ausschreibungstext als HTML. `src/lib/richText.ts` macht daraus im diff --git a/docs/03-frontend.md b/docs/03-frontend.md index 2f74b39..d9fca68 100644 --- a/docs/03-frontend.md +++ b/docs/03-frontend.md @@ -38,7 +38,7 @@ flowchart TD V --> KON["contacts/
Liste, Kacheln, Netzwerk, CV-Import"] V --> UNT["companies/
Liste, Kacheln, Group, Netzwerk"] V --> AUS["tenders/
Tabelle, Live!, Archiv"] - V --> PRJ["projects/"] + V --> PRJ["projects/
Kacheln, Karte, Marge"] V --> HIS["history/"] V --> SET["settings/
Nutzer, Stammdaten, Konto, Diktat"] KON --> NW["network/Network.tsx
d3-force + SVG"] @@ -46,6 +46,12 @@ flowchart TD 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`: * Die Listen der Unternehmen und Benutzer werden **einmal** geladen und diff --git a/docs/05-ki.md b/docs/05-ki.md index 4ccb889..554dc26 100644 --- a/docs/05-ki.md +++ b/docs/05-ki.md @@ -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 | | 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 | -| 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 | In der Historie landet, **dass** jemand den Schlüssel gewechselt hat, nie der 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` 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 > weist jeden Wert außer dem Standard mit HTTP 400 ab. `ai.js` schickt deshalb > keines. `response_format: {type:'json_object'}` funktioniert dagegen. diff --git a/docs/08-anleitung-benutzer.md b/docs/08-anleitung-benutzer.md index f3d7e09..08763fb 100644 --- a/docs/08-anleitung-benutzer.md +++ b/docs/08-anleitung-benutzer.md @@ -159,9 +159,33 @@ Zwei Untermenüs: ## Laufende Projekte -Titel, Kunde, Experte, Zeitraum, Status und Notizen. Die Notizen sind ein -WYSIWYG-Feld – im übernommenen Bestand steht dort ein Kopfblock aus dem alten -CRM und darunter der Ausschreibungstext. +Titel, Kunde, Experte, Zeitraum, Status, Tagessatz und Notizen. Die Notizen +sind ein WYSIWYG-Feld – im übernommenen Bestand steht dort ein Kopfblock aus dem +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 @@ -191,7 +215,7 @@ nichts gespeichert. Details: [04 Diktat](04-diktat.md). | --- | --- | --- | | Nutzerverwaltung | Administratoren | Benutzer anlegen, Rolle, aktiv/inaktiv | | 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 | | Diktat | alle | Spracherkennung einrichten | diff --git a/docs/10-api.md b/docs/10-api.md index 2b8ddb4..1be1359 100644 --- a/docs/10-api.md +++ b/docs/10-api.md @@ -71,6 +71,11 @@ auch Phasenwechsel, der Schalter `online` und die Zuweisung von Kontakten. `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` | 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 | | `POST /text` | Schreibhilfe: `{ task, text, prompt }` | | `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 | | `POST /test` | *admin* – Miniaufruf gegen das Modell | diff --git a/server/src/ai.js b/server/src/ai.js index ba3834c..bb29a64 100644 --- a/server/src/ai.js +++ b/server/src/ai.js @@ -111,6 +111,49 @@ export async function saveAiModel(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 { constructor(message, status = 502) { super(message); diff --git a/server/src/contactBridge.js b/server/src/contactBridge.js new file mode 100644 index 0000000..95d8883 --- /dev/null +++ b/server/src/contactBridge.js @@ -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) } }; +} diff --git a/server/src/db.js b/server/src/db.js index 9a13651..e615f7b 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -50,6 +50,7 @@ export async function initDatabase() { await migrateColumns(); await migrateContactKind(); await seedOptions(); + await renameCustomerContactKind(); await ensureAdminUser(); } @@ -85,7 +86,7 @@ async function migrateContactKind() { async function seedOptions() { if ((await getSetting('options_seeded')) === 'true') return; - const kinds = ['Experte', 'Ansprechpartner', 'Lead']; + const kinds = ['Experte', 'Ansprechpartner (Kunde)', 'Lead']; await pool.query( `INSERT IGNORE INTO icon_options (field, value, position) 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.`); } +/* + * "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. * @@ -134,6 +173,12 @@ async function migrateColumns() { ], // Das im Lebenslauf gefundene Portrait - Zwischenablage zwischen Auslesen // 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', 'photo', diff --git a/server/src/history.js b/server/src/history.js index ea5a896..750c269 100644 --- a/server/src/history.js +++ b/server/src/history.js @@ -122,6 +122,7 @@ export function diffFields(before, after, fields) { /** Menschenlesbare Namen der Felder - fuer den Text in der Historie. */ export const FIELD_LABELS = { name: 'Name', + title: 'Titel', first_name: 'Vorname', last_name: 'Nachname', position: 'Position', diff --git a/server/src/routes/ai.js b/server/src/routes/ai.js index f459d52..75c1f21 100644 --- a/server/src/routes/ai.js +++ b/server/src/routes/ai.js @@ -5,6 +5,7 @@ import { aiConfigured, aiModel, aiStatus, + listAiModels, saveAiKey, saveAiModel, } from '../ai.js'; @@ -116,6 +117,15 @@ router.get('/config', requireAdmin, (req, res) => { 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. router.patch('/config', requireAdmin, async (req, res, next) => { const body = req.body || {}; diff --git a/server/src/routes/companies.js b/server/src/routes/companies.js index 0be7754..1f31e8c 100644 --- a/server/src/routes/companies.js +++ b/server/src/routes/companies.js @@ -176,7 +176,7 @@ router.get('/:id', async (req, res, next) => { const [interviews] = await pool.query( `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 LEFT JOIN icon_users u ON u.id = i.user_id LEFT JOIN icon_contacts k ON k.id = i.contact_id diff --git a/server/src/routes/connections.js b/server/src/routes/connections.js index 8f3dcc6..60f7de2 100644 --- a/server/src/routes/connections.js +++ b/server/src/routes/connections.js @@ -17,8 +17,8 @@ router.get('/', async (req, res, next) => { try { const [rows] = await pool.query( `SELECT c.*, - CONCAT_WS(' ', a.first_name, a.last_name) AS a_name, - CONCAT_WS(' ', b.first_name, b.last_name) AS b_name + CONCAT_WS(' ', a.title, a.first_name, a.last_name) AS a_name, + CONCAT_WS(' ', b.title, b.first_name, b.last_name) AS b_name FROM icon_connections c JOIN icon_contacts a ON a.id = c.contact_a 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( - "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] ); const nameOf = (id) => names.find((row) => row.id === id)?.name ?? 'Unbekannt'; diff --git a/server/src/routes/contacts.js b/server/src/routes/contacts.js index e3e5ba0..fa6d419 100644 --- a/server/src/routes/contacts.js +++ b/server/src/routes/contacts.js @@ -22,6 +22,7 @@ router.use(requireAuth); */ const EDITABLE = [ + 'title', 'first_name', 'last_name', 'position', @@ -58,6 +59,7 @@ async function assertKnownKind(kind) { function readBody(body) { 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.last_name !== undefined) out.last_name = String(body.last_name ?? '').trim().slice(0, 120); 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(); if (q) { 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 ?)` ); params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`); @@ -663,7 +665,7 @@ router.get('/:id/cv', async (req, res, next) => { try { 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 FROM icon_contacts k WHERE k.id = ? LIMIT 1`, [id] @@ -777,7 +779,9 @@ async function loadOne(id) { } 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) { diff --git a/server/src/routes/cvs.js b/server/src/routes/cvs.js index 8cc9453..0fe7cbd 100644 --- a/server/src/routes/cvs.js +++ b/server/src/routes/cvs.js @@ -120,7 +120,7 @@ router.get('/', async (req, res, next) => { c.extracted, c.error, c.contact_id, c.uploaded_by, c.created_at, c.processed_at, c.photo IS NOT NULL AS has_photo, 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 LEFT JOIN icon_users u ON u.id = c.uploaded_by LEFT JOIN icon_contacts k ON k.id = c.contact_id diff --git a/server/src/routes/dashboard.js b/server/src/routes/dashboard.js index 5992272..4bae441 100644 --- a/server/src/routes/dashboard.js +++ b/server/src/routes/dashboard.js @@ -18,7 +18,7 @@ router.get('/', async (req, res, next) => { `SELECT (SELECT COUNT(*) FROM icon_contacts) AS contacts, (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 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, diff --git a/server/src/routes/history.js b/server/src/routes/history.js index b9026ca..905b98a 100644 --- a/server/src/routes/history.js +++ b/server/src/routes/history.js @@ -128,7 +128,7 @@ async function labelFor(entityType, entityId) { if (!entityId) return null; if (entityType === 'contact') { 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] ); return rows[0]?.label ?? null; diff --git a/server/src/routes/integrations.js b/server/src/routes/integrations.js index 4c12794..b449265 100644 --- a/server/src/routes/integrations.js +++ b/server/src/routes/integrations.js @@ -1,6 +1,7 @@ import express from 'express'; import { timingSafeEqual } from 'node:crypto'; import { pool } from '../db.js'; +import { applyBridgePatch } from '../contactBridge.js'; 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 * 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) { @@ -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; diff --git a/server/src/routes/leads.js b/server/src/routes/leads.js index 51f5859..739b923 100644 --- a/server/src/routes/leads.js +++ b/server/src/routes/leads.js @@ -93,7 +93,7 @@ const positiveOrNull = (value) => { const SELECT = ` SELECT l.*, co.name AS company_name, co.industry AS company_industry, co.logo AS company_logo, 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, u.name AS owner_name, (SELECT COUNT(*) FROM icon_lead_todos t WHERE t.lead_id = l.id) AS todo_count, diff --git a/server/src/routes/network.js b/server/src/routes/network.js index 9d81691..9aab8a3 100644 --- a/server/src/routes/network.js +++ b/server/src/routes/network.js @@ -51,7 +51,7 @@ router.get('/', async (req, res, next) => { } 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 FROM icon_contacts k LEFT JOIN icon_employments e ON e.contact_id = k.id diff --git a/server/src/routes/perinco.js b/server/src/routes/perinco.js index d6549a4..6b28fc6 100644 --- a/server/src/routes/perinco.js +++ b/server/src/routes/perinco.js @@ -1,5 +1,6 @@ import express from 'express'; import { requireAuth } from '../middleware/auth.js'; +import { applyBridgePatch } from '../contactBridge.js'; const router = express.Router(); router.use(requireAuth); @@ -7,12 +8,16 @@ router.use(requireAuth); /* * 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 * 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, * 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. * Neue Felder anzulegen war ausdrücklich nicht gewollt - was drüben kein * 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; diff --git a/server/src/routes/projects.js b/server/src/routes/projects.js index c936a5c..e2c11d5 100644 --- a/server/src/routes/projects.js +++ b/server/src/routes/projects.js @@ -29,6 +29,7 @@ const positiveOrNull = (value) => { const n = Number(value); return Number.isInteger(n) && n > 0 ? n : null; }; +const num = (value) => (value === null || value === undefined ? null : Number(value)); const dateOrNull = (value) => { const text = String(value ?? '').slice(0, 10); 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(); if (q) { 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}%`); } @@ -53,7 +54,8 @@ router.get('/', async (req, res, next) => { try { const [rows] = await pool.query( `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 LEFT JOIN icon_companies co ON co.id = p.company_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`, 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) { next(err); } @@ -174,7 +185,7 @@ async function logRelated(data, text, userId) { } if (data.contact_id) { 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] ); await logHistory({ diff --git a/server/src/routes/search.js b/server/src/routes/search.js index 012cf92..f8ed279 100644 --- a/server/src/routes/search.js +++ b/server/src/routes/search.js @@ -33,11 +33,11 @@ router.get('/', async (req, res, next) => { try { const [contacts, companies, tenders, projects, leads, history] = await Promise.all([ 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 FROM icon_contacts k 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.lead_source LIKE ? OR co.name LIKE ? ORDER BY k.last_name LIMIT ${limit}`, @@ -62,7 +62,7 @@ router.get('/', async (req, res, next) => { ), pool.query( `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 LEFT JOIN icon_companies co ON co.id = p.company_id LEFT JOIN icon_contacts k ON k.id = p.contact_id @@ -72,7 +72,7 @@ router.get('/', async (req, res, next) => { ), pool.query( `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 LEFT JOIN icon_companies co ON co.id = l.company_id LEFT JOIN icon_contacts k ON k.id = l.contact_id @@ -159,11 +159,11 @@ router.get('/mentions', async (req, res, next) => { try { 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 FROM icon_contacts k 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`, q ? [like] : [] ); @@ -227,7 +227,7 @@ router.post('/ai', async (req, res, next) => { try { 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.rate_onsite, k.rate_remote, k.linkedin, co.name AS company, 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}` ); 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 FROM icon_employments e 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( `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 LEFT JOIN icon_companies co ON co.id = p.company_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( `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 LEFT JOIN icon_companies co ON co.id = l.company_id LEFT JOIN icon_contacts k ON k.id = l.contact_id diff --git a/server/src/routes/tenders.js b/server/src/routes/tenders.js index 70db28d..3813bd9 100644 --- a/server/src/routes/tenders.js +++ b/server/src/routes/tenders.js @@ -98,7 +98,7 @@ router.get('/', async (req, res, next) => { if (ids.length > 0) { const [links] = await pool.query( `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 JOIN icon_contacts k ON k.id = tc.contact_id WHERE tc.tender_id IN (?) diff --git a/server/src/schema.sql b/server/src/schema.sql index 18ad12e..602ed3c 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -84,6 +84,9 @@ CREATE TABLE IF NOT EXISTS icon_companies ( -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS icon_contacts ( 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, last_name VARCHAR(120) NOT NULL, position VARCHAR(190) NULL, @@ -94,7 +97,7 @@ CREATE TABLE IF NOT EXISTS icon_contacts ( -- Aktueller Arbeitgeber. Die Historie der frueheren steht in -- icon_employments - daraus speist sich der Zweig "Verlassen". 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', -- Woher kommt der Kontakt (LinkedIn, Empfehlung, Portal, ...). lead_source VARCHAR(190) NULL, diff --git a/src/components/companies/Companies.tsx b/src/components/companies/Companies.tsx index a21546f..2fee6a1 100644 --- a/src/components/companies/Companies.tsx +++ b/src/components/companies/Companies.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import type { Company, DirectoryUser } from '../../types'; import { companiesApi } from '../../lib/api'; -import { useOptions } from '../../lib/options'; +import { useOptions, sortByLabel } from '../../lib/options'; import { Avatar } from '../ui/Avatar'; import { StarRating } from '../ui/StarRating'; import { Pill, PrimaryButton, Segmented, inputClass } from '../ui/form'; @@ -171,8 +171,8 @@ export const Companies: React.FC = ({ users, onChanged, openId, className={`${inputClass} w-auto`} > - {/* Gepflegte Liste zuerst, dazu was im Bestand sonst noch vorkommt. */} - {[...new Set([...industryOptions, ...industries])].map((value) => ( + {/* Gepflegte Liste, dazu was im Bestand sonst noch vorkommt - alphabetisch. */} + {sortByLabel([...new Set([...industryOptions, ...industries])]).map((value) => ( ))} diff --git a/src/components/companies/CompanyModal.tsx b/src/components/companies/CompanyModal.tsx index f69f18c..d062294 100644 --- a/src/components/companies/CompanyModal.tsx +++ b/src/components/companies/CompanyModal.tsx @@ -12,7 +12,7 @@ import { HistoryFeed } from '../history/HistoryFeed'; import { TRISTATE_LABELS } from '../contacts/contactUi'; import { useToast } from '../Toast'; import { useNavigation } from '../../lib/navigation'; -import { useOptions } from '../../lib/options'; +import { useOptions, sortByLabel } from '../../lib/options'; import { useAuth } from '../../lib/auth'; interface CompanyModalProps { @@ -264,7 +264,7 @@ export const CompanyModal: React.FC = ({ verschwinden. */} {(industryOptions.includes(form.industry) || !form.industry ? industryOptions - : [form.industry, ...industryOptions] + : sortByLabel([form.industry, ...industryOptions]) ).map((industry) => (