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 }) => ( -
+ Liste ist leer -{' '} + +
+ )} ++ 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_FIELD_HINTS[field]}
+ ++ Noch nichts hinterlegt - das Dropdown ist dann leer. +
+ ) : ( +