diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index 5810ecd..9b7a900 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -25,6 +25,7 @@ erDiagram crm_opportunities ||--o{ crm_opportunity_contacts : hat crm_users ||--o{ crm_acquisitions : created_by crm_acquisitions |o--o| crm_opportunities : "wird zu" + crm_acquisition_options ||--o{ crm_acquisitions : "fuellt Von, Anbieter, Kanal, Berater" crm_users { int id PK @@ -88,6 +89,11 @@ erDiagram datetime converted_at "uebernommen -> raus aus der Liste" int opportunity_id FK } + crm_acquisition_options { + int id PK + enum field "sender | provider | channel | consultant" + varchar value "Inhalt des Dropdowns" + } crm_events { int id PK varchar title @@ -103,7 +109,7 @@ erDiagram } ``` -Fünf Eigenheiten, die man kennen muss: +Sechs Eigenheiten, die man kennen muss: - **`crm_leads` führt zwei Bestände.** `mail_pool = 0` ist der gepflegte Kontaktbestand, `mail_pool = 1` der Vorbereich „Aus Mails" mit den Funden des @@ -120,6 +126,14 @@ Fünf Eigenheiten, die man kennen muss: - **`avatar` ist eine Data-URL in der Datenbank.** Der Browser verkleinert jedes Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden wenige Kilobyte. +- **Die Dropdowns der Akquise stehen in einer Tabelle, nicht in vier.** + `crm_acquisition_options` unterscheidet die Listen über die Spalte `field`. Die + vier Felder verhalten sich gleich, also ist jede weitere Liste ein Wert im + ENUM statt einer neuen Tabelle samt Route und Oberfläche. Der Unique-Schlüssel + auf `(field, value)` greift über die Kollation `utf8mb4_unicode_ci` auch bei + abweichender Groß-/Kleinschreibung - „hays" ist neben „Hays" kein neuer Wert. + Die Akquise-Einträge halten den **Text**, keinen Fremdschlüssel: wird ein Wert + aus der Liste entfernt, bleiben die Einträge lesbar und änderbar. - **`crm_acquisitions` hat keine Zustandsspalte.** Eingang, Archiv und Löschung ergeben sich aus `found_at` (14 bzw. 90 Tage) und `converted_at`. Eine Spalte, die ein Job umsetzen müsste, kann von der Wirklichkeit abweichen - eine diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index 0cb0a5e..44194ed 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -88,16 +88,19 @@ weg. | Feld | wofür | | --- | --- | | **Datum** | wann gefunden. Vorbelegt mit heute; entscheidet später über Archiv und Löschung | -| **Von** | wer es geschickt hat | -| **Anbieter** | Vermittler oder Portal, z. B. Hays | -| **Kanal** | Mail, Telefon, Portal, LinkedIn, XING, Empfehlung, Sonstiges | +| **Von** | Auswahl: wer es geschickt hat | +| **Anbieter** | Auswahl: Vermittler oder Portal, z. B. Hays | +| **Kanal** | Auswahl: auf welchem Weg es kam | | **Projekttitel** | das einzige Pflichtfeld | -| **Berater** | für wen das Projekt passt | +| **Berater** | Auswahl: für wen das Projekt passt | | **Link** | Adresse der Ausschreibung. Ist einer hinterlegt, wird der Titel in der Liste anklickbar und öffnet ihn in einem neuen Tab | -*Von*, *Anbieter* und *Berater* schlagen vor, was schon einmal eingetragen wurde - -antippen statt tippen. Beim Link genügt `hays.de/projekt/1`, das `https://` wird -ergänzt. +*Von*, *Anbieter*, *Kanal* und *Berater* sind **Auswahllisten**. Ihre Inhalte +stehen unter *Einstellungen → Akquise* und lassen sich dort von jedem Benutzer +pflegen - Freitext hätte aus „Hays", „hays" und „Hays AG" drei Anbieter gemacht. +Ist eine Liste noch leer, steht unter dem Feld ein Verweis dorthin. + +Beim Link genügt `hays.de/projekt/1`, das `https://` wird ergänzt. Unter **Projektdaten für die Opportunity** klappt auf, was in der Liste keine Spalte hat, später aber in der Opportunity stehen soll: Endkunde, Projektstart @@ -561,10 +564,21 @@ Datenbank. | --- | --- | --- | | **Nutzerverwaltung** | Admin | Benutzer anlegen, bearbeiten, deaktivieren, löschen | | **Mein Konto** | alle | Foto, Terminfarbe, Passwort ändern | +| **Akquise** | alle | Inhalte der Auswahllisten *Von*, *Anbieter*, *Kanal*, *Berater* | | **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktübernahme | | **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schlüssel, Ton, E-Mail | | **Whisper** | alle | Spracherkennung für das Diktat einrichten | +Im Reiter **Akquise** stehen die vier Auswahllisten des Akquise-Formulars, jede +mit einem Feld zum Hinzufügen und je Eintrag einem Stift und einem Papierkorb. +Zwei Dinge sind dabei unterschiedlich: + +* **Umbenennen** ändert die vorhandenen Akquise-Einträge mit. Aus „Hays" wird in + allen Einträgen „Hays AG"; die Meldung sagt, wie viele betroffen waren. +* **Entfernen** nimmt den Wert nur aus der Auswahl. Einträge, die ihn tragen, + behalten ihn und zeigen ihn im Formular als „nicht in der Liste" - so ändert + das Aufräumen einer Liste keine Daten. + Im Reiter **Scanner** steht das **Suchintervall (Minuten)** - also wie oft der Hintergrund-Scanner die Börsen durchsucht. diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index 44f7817..027804d 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -47,6 +47,11 @@ Mal etwas: * `migrateMailPool()` schiebt die vom Postfachabgleich angelegten Kontakte in den Vorbereich. Sie merkt sich das per Schalter in `crm_settings`, weil an den Daten selbst nicht zu erkennen ist, ob sie schon gelaufen ist. +* `seedAcquisitionOptions()` legt den Startbestand der Akquise-Dropdowns an: die + Kanäle, die vorher im Quelltext standen, plus alles, was in vorhandenen + Einträgen als Freitext steht. Auch hier ein Schalter in `crm_settings` - ohne + ihn wäre jeder Neustart ein Wiederherstellen von Werten, die jemand bewusst + entfernt hat. `ensureAdminUser()` legt nur an, wenn `crm_users` **leer** ist. Ohne `ADMIN_PASSWORD` erzeugt es ein Zufallspasswort und schreibt es einmalig ins Log - @@ -83,7 +88,7 @@ Alle unter `router.use(requireAuth)`. | `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete | | `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin | | `opportunities.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | ändern/löschen nur Ersteller, Zuständiger oder Admin | -| `acquisitions.js` | `GET /?view=eingang\|archiv\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id`, `POST /:id/opportunity` | ändern/löschen nur Ersteller oder Admin | +| `acquisitions.js` | `GET /?view=eingang\|archiv\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id`, `POST /:id/opportunity`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern/löschen nur Ersteller oder Admin; Auswahllisten jeder Angemeldete | | `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/messages/:id/restore`, `/candidates/:id/adopt`, `/candidates/:id/reject`, `/trash/empty`; `DELETE /links/:id`, `/messages/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen, zuordnen und löschen jeder; einrichten Admin | Wiederkehrende Muster in diesen Routern: @@ -259,6 +264,8 @@ Warum genau diese Konstanten festgelegt sind, steht in | `components/acquisition/Acquisitions.tsx` | Akquise und Archiv (`view`); Suche, Sortierung, Übernahme, Löschen | | `components/acquisition/AcquisitionForm.tsx` | Eingabe über der Liste; behält Datum, Anbieter, Kanal und Berater für den nächsten Eintrag | | `components/acquisition/AcquisitionTable.tsx` | die Liste; `safeLink()` lässt nur http(s) zum Link werden | +| `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen | +| `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen | | `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart | | `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs | | `components/leads/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Übernehmen, Sammelübernahme | @@ -283,7 +290,8 @@ nicht auseinanderlaufen: | `MAIL_LEAD_SOURCE`, `LeadOriginFilter` | Herkunft „Mail" in `Lead.source` und ihr Filter | | `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id | | `opportunityVolume()`, `weightedVolume()`, `formatEuro()` | Festpreis, sonst Tagessatz x Personentage; gewichtet; formatiert | -| `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_KINDS`, `ACQUISITION_CHANNELS`, `CONNECTION_TYPES` | die Aufzählungen der Datenbank | +| `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_KINDS`, `CONNECTION_TYPES` | die Aufzählungen der Datenbank | +| `ACQUISITION_OPTION_FIELDS`, `ACQUISITION_FIELD_LABELS`, `ACQUISITION_FIELD_HINTS` | die vier Auswahllisten der Akquise samt Beschriftung | | `eventColor()`, `leadFullName()`, `leadInitials()`, `contactFullName()` | Anzeigelogik | ## Datenfluss im Frontend diff --git a/docs/10-glossar.md b/docs/10-glossar.md index b62ee53..d811f60 100644 --- a/docs/10-glossar.md +++ b/docs/10-glossar.md @@ -22,6 +22,7 @@ Quelltext. | **Match** / Treffer | Die Bewertung **eines** Projekts für **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. | | **Opportunity** | Geschäft in Arbeit: Phase, Tagessatz und Laufzeit oder Projektpreis, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. | | **Akquise** | Eingang für gefundene Projekte, bevor daraus eine Opportunity wird. Tabelle `crm_acquisitions`. Nach 14 Tagen im **Archiv**, nach 90 Tagen gelöscht. | +| **Auswahllisten** (Akquise) | Inhalte der Dropdowns *Von*, *Anbieter*, *Kanal*, *Berater*. Tabelle `crm_acquisition_options`, gepflegt unter *Einstellungen → Akquise*. | | **Art** (eines Kontakts) | Recruiter, Partner oder Endkunde. Spalte `crm_leads.kind`; hieß bis 18.08.2026 „Status" mit den Werten Neu .. Verloren. | | **Papierkorb** | Im CRM gelöschte Mails. Umkehrbar, bis er geleert wird. Im Postfach crm@perinco.de bleiben die Mails in jedem Fall; der Abgleich holt gelöschte nicht wieder herein. | | **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. | diff --git a/server/src/db.js b/server/src/db.js index 21b8908..ded8c88 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -50,6 +50,7 @@ export async function initDatabase() { await migrateLeadKind(); await migrateMailPool(); + await seedAcquisitionOptions(); await ensureAdminUser(); } @@ -127,6 +128,49 @@ async function migrateMailPool() { } } +/* + * Startbestand der Akquise-Auswahllisten: die Kanaele, die bis zur Umstellung im + * Quelltext standen, plus alles, was in vorhandenen Eintraegen schon als + * Freitext steht. + * + * Laeuft genau einmal, festgehalten per Merker in crm_settings. Ohne den waere + * jeder Neustart ein Wiederherstellen: ein Kanal, den jemand in den + * Einstellungen bewusst entfernt hat, waere danach wieder da. + */ +async function seedAcquisitionOptions() { + const flag = 'acquisition_options_seeded'; + const [rows] = await pool.query('SELECT value FROM crm_settings WHERE key_name = ? LIMIT 1', [flag]); + if (rows[0]?.value === 'true') return; + + const channels = ['Mail', 'Telefon', 'Portal', 'LinkedIn', 'XING', 'Empfehlung', 'Sonstiges']; + await pool.query( + `INSERT IGNORE INTO crm_acquisition_options (field, value) + VALUES ${channels.map(() => "('channel', ?)").join(', ')}`, + channels + ); + + // Vorhandene Freitexte uebernehmen, damit kein Eintrag einen Wert traegt, den + // sein Dropdown nicht kennt. + for (const field of ['sender', 'provider', 'consultant', 'channel']) { + await pool.query( + `INSERT IGNORE INTO crm_acquisition_options (field, value) + SELECT ?, TRIM(${field}) FROM crm_acquisitions + WHERE ${field} IS NOT NULL AND TRIM(${field}) <> '' + GROUP BY TRIM(${field})`, + [field] + ); + } + + await pool.query( + `INSERT INTO crm_settings (key_name, value) VALUES (?, 'true') + ON DUPLICATE KEY UPDATE value = 'true'`, + [flag] + ); + + const [[count]] = await pool.query('SELECT COUNT(*) AS count FROM crm_acquisition_options'); + console.log(`[Akquise] Auswahllisten angelegt - ${count.count} Eintrag/Eintraege.`); +} + async function ensureAdminUser() { const [rows] = await pool.query('SELECT COUNT(*) AS count FROM crm_users'); if (rows[0].count > 0) return; diff --git a/server/src/routes/acquisitions.js b/server/src/routes/acquisitions.js index b91e423..27c885c 100644 --- a/server/src/routes/acquisitions.js +++ b/server/src/routes/acquisitions.js @@ -24,15 +24,14 @@ const ARCHIVE_AFTER_DAYS = 14; /** Ab hier loescht das Archiv selbst. */ const DELETE_AFTER_DAYS = 90; -const CHANNELS = new Set([ - 'Mail', - 'Telefon', - 'Portal', - 'LinkedIn', - 'XING', - 'Empfehlung', - 'Sonstiges', -]); +/* + * Felder, deren Inhalte als Auswahlliste in den Einstellungen gepflegt werden. + * Die Werte selbst stehen in crm_acquisition_options, nicht hier - deshalb + * prueft das Backend beim Speichern eines Eintrags nur die Laenge und nicht die + * Zugehoerigkeit zur Liste: wer einen Wert aus der Liste entfernt, soll damit + * nicht jeden alten Eintrag unaenderbar machen. + */ +const OPTION_FIELDS = ['sender', 'provider', 'channel', 'consultant']; // `link` fehlt hier absichtlich: der Wert wird eigens geprueft (normalizeLink). const TEXT_FIELDS = [ @@ -186,6 +185,16 @@ async function purgeOldEntries() { } } +/** Alle Auswahllisten als { sender: [...], provider: [...], ... }. */ +async function loadOptions() { + const [rows] = await pool.query( + 'SELECT id, field, value FROM crm_acquisition_options ORDER BY field, value' + ); + const options = { sender: [], provider: [], channel: [], consultant: [] }; + for (const row of rows) options[row.field].push({ id: row.id, value: row.value }); + return options; +} + /** Aendern und loeschen darf der Ersteller und jeder Admin. */ function mayModify(user, row) { return user.role === 'admin' || row.created_by === user.id; @@ -215,8 +224,8 @@ function readFields(body, { partial }) { if (!partial || body?.channel !== undefined) { const channel = emptyToNull(body?.channel); - if (channel !== null && !CHANNELS.has(channel)) { - return { error: 'Unbekannter Kanal.' }; + if (channel !== null && channel.length > 60) { + return { error: 'Der Kanal ist zu lang (max. 60 Zeichen).' }; } columns.channel = channel; } @@ -249,6 +258,130 @@ function readFields(body, { partial }) { return { columns }; } +// --------------------------------------------------------------------------- +// Auswahllisten (Einstellungen -> Akquise) +// +// Pflegen darf jeder Angemeldete. Wer ein Projekt eintraegt und dabei auf einen +// neuen Anbieter stoesst, muss weiterarbeiten koennen - eine Liste, die nur ein +// Administrator ergaenzen kann, blockiert genau dann die Eingabe. +// --------------------------------------------------------------------------- + +// GET /api/acquisitions/options +router.get('/options', async (req, res, next) => { + try { + res.json(await loadOptions()); + } catch (err) { + next(err); + } +}); + +// POST /api/acquisitions/options +router.post('/options', async (req, res, next) => { + const field = String(req.body?.field ?? ''); + if (!OPTION_FIELDS.includes(field)) return res.status(400).json({ error: 'Unbekanntes Feld.' }); + + const value = emptyToNull(req.body?.value); + if (!value) return res.status(400).json({ error: 'Ein Wert ist erforderlich.' }); + const limit = field === 'channel' ? 60 : 190; + if (value.length > limit) { + return res.status(400).json({ error: `Der Wert ist zu lang (max. ${limit} Zeichen).` }); + } + + try { + const [result] = await pool.query( + 'INSERT INTO crm_acquisition_options (field, value, created_by) VALUES (?, ?, ?)', + [field, value, req.user.id] + ); + res.status(201).json({ id: result.insertId, field, value }); + } catch (err) { + if (err?.code === 'ER_DUP_ENTRY') { + return res.status(409).json({ error: 'Dieser Wert steht schon in der Liste.' }); + } + next(err); + } +}); + +/* + * PATCH /api/acquisitions/options/:id + * + * Umbenennen zieht die vorhandenen Eintraege mit: wird "Hays" zu "Hays AG", soll + * die Liste nicht auf einmal zwei Anbieter zeigen, von denen einer in keinem + * Dropdown mehr steht. + */ +router.patch('/options/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Options-ID.' }); + + const value = emptyToNull(req.body?.value); + if (!value) return res.status(400).json({ error: 'Ein Wert ist erforderlich.' }); + + const conn = await pool.getConnection(); + try { + const [rows] = await conn.query( + 'SELECT field, value FROM crm_acquisition_options WHERE id = ? LIMIT 1', + [id] + ); + const existing = rows[0]; + if (!existing) return res.status(404).json({ error: 'Eintrag der Liste nicht gefunden.' }); + + const limit = existing.field === 'channel' ? 60 : 190; + if (value.length > limit) { + return res.status(400).json({ error: `Der Wert ist zu lang (max. ${limit} Zeichen).` }); + } + if (value === existing.value) return res.json({ id, field: existing.field, value, updated: 0 }); + + await conn.beginTransaction(); + await conn.query('UPDATE crm_acquisition_options SET value = ? WHERE id = ?', [value, id]); + const [updated] = await conn.query( + `UPDATE crm_acquisitions SET ${existing.field} = ? WHERE ${existing.field} = ?`, + [value, existing.value] + ); + await conn.commit(); + + res.json({ id, field: existing.field, value, updated: updated.affectedRows }); + } catch (err) { + await conn.rollback().catch(() => {}); + if (err?.code === 'ER_DUP_ENTRY') { + return res.status(409).json({ error: 'Dieser Wert steht schon in der Liste.' }); + } + next(err); + } finally { + conn.release(); + } +}); + +/* + * DELETE /api/acquisitions/options/:id + * + * Nur die Auswahl verschwindet. Die Eintraege behalten ihren Wert - sonst wuerde + * das Aufraeumen einer Liste stillschweigend Daten aendern. Wie viele Eintraege + * betroffen sind, steht in der Antwort, damit die Oberflaeche es sagen kann. + */ +router.delete('/options/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Options-ID.' }); + + try { + const [rows] = await pool.query( + 'SELECT field, value FROM crm_acquisition_options WHERE id = ? LIMIT 1', + [id] + ); + const existing = rows[0]; + if (!existing) return res.status(404).json({ error: 'Eintrag der Liste nicht gefunden.' }); + + const [[used]] = await pool.query( + `SELECT COUNT(*) AS count FROM crm_acquisitions + WHERE converted_at IS NULL AND ${existing.field} = ?`, + [existing.value] + ); + + await pool.query('DELETE FROM crm_acquisition_options WHERE id = ?', [id]); + res.json({ ok: true, still_in_use: Number(used.count) }); + } catch (err) { + next(err); + } +}); + // GET /api/acquisitions?view=eingang|archiv|alle router.get('/', async (req, res, next) => { try { @@ -288,31 +421,12 @@ router.get('/', async (req, res, next) => { [ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS] ); - /* - * Vorschlaege fuer die Eingabe, aus dem gesamten Bestand - nicht nur aus der - * gerade gezeigten Liste. Wer denselben Anbieter zum zwanzigsten Mal - * eintraegt, soll ihn nicht zum zwanzigsten Mal ausschreiben muessen. - */ - const [suggestionRows] = await pool.query( - // Klammern sind Pflicht: ORDER BY und LIMIT in einem UNION-Zweig gehoeren - // sonst zur Gesamtabfrage. - `(SELECT 'provider' AS field, provider AS value FROM crm_acquisitions - WHERE provider IS NOT NULL GROUP BY provider ORDER BY MAX(found_at) DESC LIMIT 60) - UNION ALL - (SELECT 'consultant', consultant FROM crm_acquisitions - WHERE consultant IS NOT NULL GROUP BY consultant ORDER BY MAX(found_at) DESC LIMIT 60) - UNION ALL - (SELECT 'sender', sender FROM crm_acquisitions - WHERE sender IS NOT NULL GROUP BY sender ORDER BY MAX(found_at) DESC LIMIT 60)` - ); - const suggestions = { providers: [], consultants: [], senders: [] }; - const bucket = { provider: 'providers', consultant: 'consultants', sender: 'senders' }; - for (const row of suggestionRows) suggestions[bucket[row.field]].push(row.value); - + // Die Auswahllisten kommen mit: die Eingabe braucht sie sofort, und ein + // zweiter Aufruf nur fuer vier kurze Listen waere verschenkt. res.json({ archive_after_days: ARCHIVE_AFTER_DAYS, delete_after_days: DELETE_AFTER_DAYS, - suggestions, + options: await loadOptions(), counts: { eingang: Number(counts.eingang ?? 0), archiv: Number(counts.archiv ?? 0), diff --git a/server/src/schema.sql b/server/src/schema.sql index e8c3491..f276c30 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -397,3 +397,26 @@ CREATE TABLE IF NOT EXISTS crm_acquisitions ( CONSTRAINT fk_crm_acq_opp FOREIGN KEY (opportunity_id) REFERENCES crm_opportunities (id) ON DELETE SET NULL, CONSTRAINT fk_crm_acq_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +/* + * Auswahllisten der Akquise. "Von", "Anbieter", "Kanal" und "Berater" sind + * Dropdowns, deren Inhalte in den Einstellungen gepflegt werden - Freitext hat + * aus "Hays", "hays" und "Hays AG" drei Anbieter gemacht. + * + * Bewusst eine Tabelle mit Feldkennzeichen statt vier Tabellen: die Felder + * verhalten sich gleich, und jede weitere Liste ist damit ein Wert im ENUM statt + * einer neuen Tabelle samt Route. + * + * Der Unique-Schluessel greift ueber die Kollation utf8mb4_unicode_ci auch bei + * abweichender Gross-/Kleinschreibung: "hays" ist neben "Hays" kein neuer Wert. + */ +CREATE TABLE IF NOT EXISTS crm_acquisition_options ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + field ENUM('sender','provider','channel','consultant') NOT NULL, + value VARCHAR(190) NOT NULL, + created_by INT UNSIGNED NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_crm_acq_opt (field, value), + CONSTRAINT fk_crm_acq_opt_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/components/acquisition/AcquisitionForm.tsx b/src/components/acquisition/AcquisitionForm.tsx index 9c5e05e..6cb82f3 100644 --- a/src/components/acquisition/AcquisitionForm.tsx +++ b/src/components/acquisition/AcquisitionForm.tsx @@ -1,13 +1,15 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { DictateTextarea } from '../ui/DictationField'; -import { inputClass, labelClass } from '../leads/leadUi'; +import { OptionSelect, fieldClass } from './acquisitionUi'; +import { labelClass } from '../leads/leadUi'; import { useToast } from '../Toast'; +import { useNavigation } from '../../lib/navigation'; import { acquisitionsApi, type AcquisitionInput } from '../../lib/api'; import { - ACQUISITION_CHANNELS, + ACQUISITION_FIELD_LABELS, formatEuro, type Acquisition, - type AcquisitionChannel, + type AcquisitionOptions, } from '../../types'; /* @@ -23,7 +25,8 @@ import { interface AcquisitionFormProps { /** Gesetzt = Bearbeiten, sonst Neuanlage. */ editing: Acquisition | null; - suggestions: { providers: string[]; consultants: string[]; senders: string[] }; + /** Inhalte der Dropdowns, gepflegt unter Einstellungen -> Akquise. */ + options: AcquisitionOptions; onSaved: () => void; onCancelEdit: () => void; } @@ -74,42 +77,15 @@ function toFormState(entry: Acquisition | null): FormState { }; } -/** Freitextfeld mit Vorschlagsliste aus den bisherigen Eintraegen. */ -const SuggestInput: React.FC<{ - id: string; - label: string; - value: string; - options: string[]; - placeholder?: string; - onChange: (value: string) => void; -}> = ({ id, label, value, options, placeholder, onChange }) => ( -
- - onChange(event.target.value)} - placeholder={placeholder} - className={inputClass} - /> - - {options.map((option) => ( - -
-); - export const AcquisitionForm: React.FC = ({ editing, - suggestions, + options, onSaved, onCancelEdit, }) => { const { addToast } = useToast(); + const { goTo } = useNavigation(); + const openSettings = () => goTo('Einstellungen', { tab: 'akquise' }); const [form, setForm] = useState(() => toFormState(editing)); const [showMore, setShowMore] = useState(false); const [isSaving, setIsSaving] = useState(false); @@ -172,7 +148,7 @@ export const AcquisitionForm: React.FC = ({ found_at: form.found_at || today(), sender: form.sender.trim() || null, provider: form.provider.trim() || null, - channel: (form.channel as AcquisitionChannel) || null, + channel: form.channel || null, title: form.title.trim(), consultant: form.consultant.trim() || null, link: form.link.trim() || null, @@ -265,43 +241,33 @@ export const AcquisitionForm: React.FC = ({ type="date" value={form.found_at} onChange={(event) => set('found_at', event.target.value)} - className={inputClass} + className={fieldClass} /> - set('sender', value)} + onOpenSettings={openSettings} /> - set('provider', value)} + onOpenSettings={openSettings} + /> + set('channel', value)} + onOpenSettings={openSettings} /> -
- - -
@@ -316,16 +282,16 @@ export const AcquisitionForm: React.FC = ({ value={form.title} onChange={(event) => set('title', event.target.value)} placeholder="z. B. SAP FI Rollout Süddeutschland" - className={inputClass} + className={fieldClass} />
- set('consultant', value)} + onOpenSettings={openSettings} />
@@ -376,7 +342,7 @@ export const AcquisitionForm: React.FC = ({ value={form.company} onChange={(event) => set('company', event.target.value)} placeholder="Firma hinter dem Projekt" - className={inputClass} + className={fieldClass} />
@@ -388,7 +354,7 @@ export const AcquisitionForm: React.FC = ({ type="date" value={form.start_date} onChange={(event) => set('start_date', event.target.value)} - className={inputClass} + className={fieldClass} />
@@ -400,7 +366,7 @@ export const AcquisitionForm: React.FC = ({ type="date" value={form.end_date} onChange={(event) => set('end_date', event.target.value)} - className={inputClass} + className={fieldClass} />
@@ -416,7 +382,7 @@ export const AcquisitionForm: React.FC = ({ value={form.daily_rate} onChange={(event) => set('daily_rate', event.target.value)} placeholder="1150" - className={inputClass} + className={fieldClass} />
@@ -429,7 +395,7 @@ export const AcquisitionForm: React.FC = ({ value={form.duration_days} onChange={(event) => set('duration_days', event.target.value)} placeholder="40" - className={inputClass} + className={fieldClass} />
@@ -442,7 +408,7 @@ export const AcquisitionForm: React.FC = ({ value={form.fixed_price} onChange={(event) => set('fixed_price', event.target.value)} placeholder="Festpreis statt Tagessatz × Laufzeit" - className={inputClass} + className={fieldClass} />
@@ -472,7 +438,7 @@ export const AcquisitionForm: React.FC = ({ value={form.next_step} onChange={(event) => set('next_step', event.target.value)} placeholder="z. B. Profil bis Donnerstag schicken" - className={inputClass} + className={fieldClass} /> diff --git a/src/components/acquisition/Acquisitions.tsx b/src/components/acquisition/Acquisitions.tsx index eda48fe..46d3f08 100644 --- a/src/components/acquisition/Acquisitions.tsx +++ b/src/components/acquisition/Acquisitions.tsx @@ -9,7 +9,7 @@ import { ConfirmDialog } from '../ui/Modal'; import { useToast } from '../Toast'; import { useNavigation } from '../../lib/navigation'; import { acquisitionsApi } from '../../lib/api'; -import type { Acquisition, AcquisitionList } from '../../types'; +import { EMPTY_ACQUISITION_OPTIONS, type Acquisition, type AcquisitionList } from '../../types'; /* * Akquise: gefundene Projekte in einer Liste, mit der Eingabe darueber. @@ -174,7 +174,7 @@ export const Acquisitions: React.FC = ({ view = 'eingang', re ) : ( load(true)} onCancelEdit={() => setEditing(null)} /> diff --git a/src/components/acquisition/acquisitionUi.tsx b/src/components/acquisition/acquisitionUi.tsx new file mode 100644 index 0000000..85d5a32 --- /dev/null +++ b/src/components/acquisition/acquisitionUi.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { labelClass } from '../leads/leadUi'; +import type { AcquisitionOption } from '../../types'; + +/* + * Gemeinsame Bausteine der Akquise. + * + * `fieldClass` ist die abgesetzte Variante von `inputClass`: leicht getoente + * Flaeche und ein deutlicherer Rahmen. Auf der weissen Karte der Eingabe gingen + * die Felder sonst unter - im Formular stehen bis zu fuenfzehn davon + * nebeneinander, und man muss sehen, wo eines anfaengt und aufhoert. Beim + * Hineinklicken wird das Feld weiss, damit klar ist, wo man schreibt. + */ +export const fieldClass = + 'w-full rounded-xl border border-ink-300 bg-ink-50 px-4 py-2.5 text-sm text-ink-900 outline-none transition hover:border-ink-400 focus:border-brand-500 focus:bg-surface focus:ring-2 focus:ring-brand-500/25'; + +interface OptionSelectProps { + id: string; + label: string; + value: string; + options: AcquisitionOption[]; + onChange: (value: string) => void; + /** Fuehrt zu Einstellungen -> Akquise, wo die Liste gepflegt wird. */ + onOpenSettings: () => void; +} + +/** + * Auswahlfeld fuer "Von", "Anbieter", "Kanal" und "Berater". + * + * Steht im Eintrag ein Wert, den die Liste nicht (mehr) kennt, wird er zusaetzlich + * angeboten und als solcher gekennzeichnet. Ohne das wuerde das Bearbeiten eines + * alten Eintrags den Wert stillschweigend auf den ersten Listeneintrag aendern. + */ +export const OptionSelect: React.FC = ({ + id, + label, + value, + options, + onChange, + onOpenSettings, +}) => { + const isUnlisted = value !== '' && !options.some((option) => option.value === value); + + return ( +
+ + + + {options.length === 0 && ( +

+ Liste ist leer -{' '} + +

+ )} +
+ ); +}; diff --git a/src/components/settings/AcquisitionSettings.tsx b/src/components/settings/AcquisitionSettings.tsx new file mode 100644 index 0000000..6366e92 --- /dev/null +++ b/src/components/settings/AcquisitionSettings.tsx @@ -0,0 +1,310 @@ +import React, { useEffect, useState } from 'react'; +import { fieldClass } from '../acquisition/acquisitionUi'; +import { labelClass } from '../leads/leadUi'; +import { ConfirmDialog } from '../ui/Modal'; +import { useToast } from '../Toast'; +import { acquisitionOptionsApi } from '../../lib/api'; +import { + ACQUISITION_FIELD_HINTS, + ACQUISITION_FIELD_LABELS, + ACQUISITION_OPTION_FIELDS, + EMPTY_ACQUISITION_OPTIONS, + type AcquisitionOption, + type AcquisitionOptionField, + type AcquisitionOptions, +} from '../../types'; + +/* + * Inhalte der Akquise-Dropdowns pflegen: "Von", "Anbieter", "Kanal", "Berater". + * + * Vier gleich aufgebaute Listen statt vier Sonderfaelle - die Felder verhalten + * sich gleich, also sieht ihre Pflege auch gleich aus. + * + * Umbenennen zieht die vorhandenen Akquise-Eintraege mit, Loeschen nicht: eine + * Liste aufzuraeumen darf keine Daten aendern. Beide Antworten sagen, wie viele + * Eintraege betroffen sind, und das steht dann auch in der Meldung. + */ + +const PlusIcon = ( + + + +); + +const OptionRow: React.FC<{ + option: AcquisitionOption; + onRenamed: (value: string) => Promise; + onDelete: () => void; +}> = ({ option, onRenamed, onDelete }) => { + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(option.value); + const [isSaving, setIsSaving] = useState(false); + + // Wird der Wert von aussen umbenannt (oder die Liste neu geladen), soll der + // Entwurf nicht auf dem alten Stand stehen bleiben. + useEffect(() => { + setDraft(option.value); + }, [option.value]); + + const save = async () => { + const value = draft.trim(); + if (value === '' || value === option.value) { + setDraft(option.value); + setIsEditing(false); + return; + } + setIsSaving(true); + try { + await onRenamed(value); + setIsEditing(false); + } finally { + setIsSaving(false); + } + }; + + return ( +
  • + {isEditing ? ( + setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + save(); + } + if (event.key === 'Escape') { + setDraft(option.value); + setIsEditing(false); + } + }} + onBlur={save} + aria-label={`${option.value} umbenennen`} + className={`${fieldClass} py-1.5`} + /> + ) : ( + <> + + {option.value} + + + + + )} +
  • + ); +}; + +export const AcquisitionSettings: React.FC = () => { + const { addToast } = useToast(); + const [options, setOptions] = useState(EMPTY_ACQUISITION_OPTIONS); + const [isLoading, setIsLoading] = useState(true); + const [drafts, setDrafts] = useState>({ + sender: '', + provider: '', + channel: '', + consultant: '', + }); + const [toDelete, setToDelete] = useState<{ + field: AcquisitionOptionField; + option: AcquisitionOption; + } | null>(null); + + const load = async (silent = false) => { + if (!silent) setIsLoading(true); + try { + setOptions(await acquisitionOptionsApi.list()); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Auswahllisten nicht abrufbar.', 'error'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + load(); + // Einmal beim Oeffnen des Reiters. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const add = async (field: AcquisitionOptionField) => { + const value = drafts[field].trim(); + if (!value) return; + try { + await acquisitionOptionsApi.create(field, value); + setDrafts((prev) => ({ ...prev, [field]: '' })); + addToast(`„${value}" zu ${ACQUISITION_FIELD_LABELS[field]} hinzugefügt.`, 'success'); + await load(true); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Hinzufügen fehlgeschlagen.', 'error'); + } + }; + + const rename = async (option: AcquisitionOption, value: string) => { + try { + const result = await acquisitionOptionsApi.update(option.id, value); + addToast( + result.updated > 0 + ? `Umbenannt - ${result.updated} Eintrag/Einträge in der Akquise mitgeändert.` + : 'Umbenannt.', + 'success' + ); + await load(true); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Umbenennen fehlgeschlagen.', 'error'); + throw err; + } + }; + + const confirmDelete = async () => { + if (!toDelete) return; + try { + const result = await acquisitionOptionsApi.remove(toDelete.option.id); + addToast( + result.still_in_use > 0 + ? `Aus der Liste entfernt. ${result.still_in_use} Eintrag/Einträge behalten den Wert.` + : 'Aus der Liste entfernt.', + 'success' + ); + await load(true); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Entfernen fehlgeschlagen.', 'error'); + } finally { + setToDelete(null); + } + }; + + if (isLoading) { + return ( +
    + +
    + ); + } + + return ( +
    +
    +

    Auswahllisten der Akquise

    +

    + Diese vier Listen füllen die Dropdowns im Formular unter Akquise. Feste Auswahl + statt Freitext, damit aus „Hays", „hays" und „Hays AG" nicht drei Anbieter werden. + Pflegen darf jeder angemeldete Benutzer - wer beim Eintragen auf einen neuen Anbieter + stößt, soll weiterarbeiten können. +

    +

    + Umbenennen ändert die vorhandenen Akquise-Einträge mit. + Entfernen nimmt den Wert nur aus der Auswahl - Einträge, die ihn + tragen, behalten ihn und zeigen ihn als „nicht in der Liste". +

    +
    + +
    + {ACQUISITION_OPTION_FIELDS.map((field) => ( +
    +
    +

    {ACQUISITION_FIELD_LABELS[field]}

    + + {options[field].length} {options[field].length === 1 ? 'Eintrag' : 'Einträge'} + +
    +

    {ACQUISITION_FIELD_HINTS[field]}

    + +
    + +
    + + setDrafts((prev) => ({ ...prev, [field]: event.target.value })) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + add(field); + } + }} + placeholder={`Neuer Wert für ${ACQUISITION_FIELD_LABELS[field]}`} + className={fieldClass} + /> + +
    +
    + + {options[field].length === 0 ? ( +

    + Noch nichts hinterlegt - das Dropdown ist dann leer. +

    + ) : ( +
      + {options[field].map((option) => ( + rename(option, value)} + onDelete={() => setToDelete({ field, option })} + /> + ))} +
    + )} +
    + ))} +
    + + {toDelete && ( + setToDelete(null)} + /> + )} +
    + ); +}; diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index a118a92..a70b740 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { UserManagement } from './UserManagement'; import { MailSettings } from './MailSettings'; +import { AcquisitionSettings } from './AcquisitionSettings'; import { ScannerSettings } from './ScannerSettings'; import { ColorPicker } from './ColorPicker'; import { AvatarPicker } from '../ui/AvatarPicker'; @@ -14,6 +15,7 @@ import { ADMIN_SETTINGS_TABS, userColor, type SettingsTab } from '../../types'; const TABS: { id: SettingsTab; label: string }[] = [ { id: 'users', label: 'Nutzerverwaltung' }, { id: 'account', label: 'Mein Konto' }, + { id: 'akquise', label: 'Akquise' }, { id: 'mail', label: 'Postfach' }, { id: 'scanner', label: 'Scanner' }, { id: 'whisper', label: 'Whisper' }, @@ -178,7 +180,9 @@ const AccountTab: React.FC = () => { export const Settings: React.FC<{ initialTab?: SettingsTab }> = ({ initialTab }) => { const { isAdmin } = useAuth(); - // Nutzerverwaltung und Scanner nur fuer Admins; Konto und Whisper fuer alle. + // Nutzerverwaltung, Postfach und Scanner nur fuer Admins; Konto, Akquise und + // Whisper fuer alle - die Akquise-Auswahllisten sind Arbeitsmittel, keine + // Konfiguration, und wer eintraegt muss sie ergaenzen koennen. const tabs = TABS.filter((item) => isAdmin || !ADMIN_SETTINGS_TABS.includes(item.id)); const defaultTab: SettingsTab = isAdmin ? 'users' : 'account'; @@ -217,6 +221,7 @@ export const Settings: React.FC<{ initialTab?: SettingsTab }> = ({ initialTab }) {tab === 'users' && isAdmin && } {tab === 'account' && } + {tab === 'akquise' && } {tab === 'mail' && isAdmin && } {tab === 'scanner' && isAdmin && } {tab === 'whisper' && } diff --git a/src/lib/api.ts b/src/lib/api.ts index 144db13..53bf89f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,6 +1,9 @@ import type { Acquisition, AcquisitionList, + AcquisitionOption, + AcquisitionOptionField, + AcquisitionOptions, AppUser, CalendarEvent, Lead, @@ -245,6 +248,32 @@ export const acquisitionsApi = { }), }; +/** + * Inhalte der Dropdowns "Von", "Anbieter", "Kanal" und "Berater". Pflegen darf + * jeder Angemeldete: eine Liste, die nur ein Administrator ergaenzen kann, wuerde + * die Eingabe genau dann blockieren, wenn ein neuer Anbieter auftaucht. + */ +export const acquisitionOptionsApi = { + list: () => request('/acquisitions/options'), + + create: (field: AcquisitionOptionField, value: string) => + request('/acquisitions/options', { + method: 'POST', + body: JSON.stringify({ field, value }), + }), + + /** Umbenennen zieht die vorhandenen Akquise-Eintraege mit; `updated` sagt wie viele. */ + update: (id: number, value: string) => + request( + `/acquisitions/options/${id}`, + { method: 'PATCH', body: JSON.stringify({ value }) } + ), + + /** Entfernt nur die Auswahl; `still_in_use` zaehlt die Eintraege, die den Wert behalten. */ + remove: (id: number) => + request<{ ok: true; still_in_use: number }>(`/acquisitions/options/${id}`, { method: 'DELETE' }), +}; + // ---------- Interviews (Termine) ---------- export interface EventInput { diff --git a/src/types.ts b/src/types.ts index cae20e5..81d1e4d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,7 +21,7 @@ export type ViewState = * `account` und `whisper` stehen jedem offen - jeder pflegt sein eigenes Konto * und richtet sein eigenes Diktat ein. */ -export type SettingsTab = 'users' | 'account' | 'mail' | 'scanner' | 'whisper'; +export type SettingsTab = 'users' | 'account' | 'akquise' | 'mail' | 'scanner' | 'whisper'; export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'scanner']; @@ -251,20 +251,43 @@ export type OpportunityViewMode = 'grid' | 'list' | 'pipeline'; // ---------- Akquise ---------- /** - * Wie das Projekt zu uns kam. Feste Auswahl, damit sich die Liste nach dem Kanal - * gruppieren laesst - als Freitext waeren "Mail", "E-Mail" und "mail" drei Kanaele. + * Felder der Akquise, die aus einer Auswahlliste kommen. Die Werte stehen in der + * Datenbank (crm_acquisition_options) und werden unter Einstellungen -> Akquise + * gepflegt - als Freitext waeren "Hays", "hays" und "Hays AG" drei Anbieter. */ -export const ACQUISITION_CHANNELS = [ - 'Mail', - 'Telefon', - 'Portal', - 'LinkedIn', - 'XING', - 'Empfehlung', - 'Sonstiges', -] as const; +export const ACQUISITION_OPTION_FIELDS = ['sender', 'provider', 'channel', 'consultant'] as const; -export type AcquisitionChannel = (typeof ACQUISITION_CHANNELS)[number]; +export type AcquisitionOptionField = (typeof ACQUISITION_OPTION_FIELDS)[number]; + +/** Beschriftung der Felder - dieselbe in der Liste, im Formular und in den Einstellungen. */ +export const ACQUISITION_FIELD_LABELS: Record = { + sender: 'Von', + provider: 'Anbieter', + channel: 'Kanal', + consultant: 'Berater', +}; + +/** Was in der jeweiligen Liste steht - als Hilfe in den Einstellungen. */ +export const ACQUISITION_FIELD_HINTS: Record = { + sender: 'Personen, von denen Projekte kommen', + provider: 'Vermittler und Portale, z. B. Hays', + channel: 'Auf welchem Weg das Projekt kam', + consultant: 'Eigene Berater, für die Projekte gesucht werden', +}; + +export interface AcquisitionOption { + id: number; + value: string; +} + +export type AcquisitionOptions = Record; + +export const EMPTY_ACQUISITION_OPTIONS: AcquisitionOptions = { + sender: [], + provider: [], + channel: [], + consultant: [], +}; /** * Ein gefundenes Projekt, bevor daraus eine Opportunity wird. @@ -280,7 +303,7 @@ export interface Acquisition { sender: string | null; /** "Anbieter": Vermittler oder Portal. */ provider: string | null; - channel: AcquisitionChannel | null; + channel: string | null; title: string; /** "Berater", fuer den es passt. */ consultant: string | null; @@ -312,8 +335,8 @@ export interface AcquisitionList { archive_after_days: number; delete_after_days: number; counts: { eingang: number; archiv: number }; - /** Bisher benutzte Werte aus dem gesamten Bestand, fuer die Eingabehilfen. */ - suggestions: { providers: string[]; consultants: string[]; senders: string[] }; + /** Inhalte der Dropdowns; kommen mit, damit die Eingabe sie sofort hat. */ + options: AcquisitionOptions; entries: Acquisition[]; }