fix(akquise): Anbieter wieder als Freitext mit Vorschlaegen

Das Dropdown fiel dem Feld zur Last: neue Vermittler tauchen zu oft auf, als
dass das Eintragen an einer gepflegten Liste haengen sollte. Anbieter ist wieder
ein Textfeld, jetzt mit Vorschlagsliste aus den bisherigen Eintraegen - der
zuletzt benutzte zuerst.

In den Einstellungen bleiben die drei Listen "Von", "Kanal" und "Berater".
dropProviderOptions() nimmt 'provider' aus dem ENUM der Optionstabelle und
loescht die dort gepflegten Werte; die Anbieter der Akquise-Eintraege bleiben
unberuehrt, sie stehen als Text in crm_acquisitions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-18 12:21:33 +02:00
co-authored by Claude Opus 5
parent 05cc261900
commit 23087a12fb
11 changed files with 135 additions and 50 deletions
+9 -7
View File
@@ -25,7 +25,7 @@ erDiagram
crm_opportunities ||--o{ crm_opportunity_contacts : hat crm_opportunities ||--o{ crm_opportunity_contacts : hat
crm_users ||--o{ crm_acquisitions : created_by crm_users ||--o{ crm_acquisitions : created_by
crm_acquisitions |o--o| crm_opportunities : "wird zu" crm_acquisitions |o--o| crm_opportunities : "wird zu"
crm_acquisition_options ||--o{ crm_acquisitions : "fuellt Von, Anbieter, Kanal, Berater" crm_acquisition_options ||--o{ crm_acquisitions : "fuellt Von, Kanal, Berater"
crm_users { crm_users {
int id PK int id PK
@@ -91,7 +91,7 @@ erDiagram
} }
crm_acquisition_options { crm_acquisition_options {
int id PK int id PK
enum field "sender | provider | channel | consultant" enum field "sender | channel | consultant"
varchar value "Inhalt des Dropdowns" varchar value "Inhalt des Dropdowns"
} }
crm_events { crm_events {
@@ -126,14 +126,16 @@ Sechs Eigenheiten, die man kennen muss:
- **`avatar` ist eine Data-URL in der Datenbank.** Der Browser verkleinert jedes - **`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 Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden
wenige Kilobyte. wenige Kilobyte.
- **Die Dropdowns der Akquise stehen in einer Tabelle, nicht in vier.** - **Die Dropdowns der Akquise stehen in einer Tabelle, nicht in drei.**
`crm_acquisition_options` unterscheidet die Listen über die Spalte `field`. Die `crm_acquisition_options` unterscheidet die Listen über die Spalte `field`. Die
vier Felder verhalten sich gleich, also ist jede weitere Liste ein Wert im Felder verhalten sich gleich, also ist jede weitere Liste ein Wert im ENUM
ENUM statt einer neuen Tabelle samt Route und Oberfläche. Der Unique-Schlüssel statt einer neuen Tabelle samt Route und Oberfläche. Der Unique-Schlüssel auf
auf `(field, value)` greift über die Kollation `utf8mb4_unicode_ci` auch bei `(field, value)` greift über die Kollation `utf8mb4_unicode_ci` auch bei
abweichender Groß-/Kleinschreibung - „hays" ist neben „Hays" kein neuer Wert. abweichender Groß-/Kleinschreibung - „mail" ist neben „Mail" kein neuer Wert.
Die Akquise-Einträge halten den **Text**, keinen Fremdschlüssel: wird ein 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. aus der Liste entfernt, bleiben die Einträge lesbar und änderbar.
`crm_acquisitions.provider` (Anbieter) gehört nicht dazu - das Feld ist
Freitext, seine Vorschläge kommen aus den vorhandenen Einträgen.
- **`crm_acquisitions` hat keine Zustandsspalte.** Eingang, Archiv und Löschung - **`crm_acquisitions` hat keine Zustandsspalte.** Eingang, Archiv und Löschung
ergeben sich aus `found_at` (14 bzw. 90 Tage) und `converted_at`. Eine Spalte, 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 die ein Job umsetzen müsste, kann von der Wirklichkeit abweichen - eine
+11 -7
View File
@@ -89,16 +89,19 @@ weg.
| --- | --- | | --- | --- |
| **Datum** | wann gefunden. Vorbelegt mit heute; entscheidet später über Archiv und Löschung | | **Datum** | wann gefunden. Vorbelegt mit heute; entscheidet später über Archiv und Löschung |
| **Von** | Auswahl: wer es geschickt hat | | **Von** | Auswahl: wer es geschickt hat |
| **Anbieter** | Auswahl: Vermittler oder Portal, z. B. Hays | | **Anbieter** | Freitext: Vermittler oder Portal, z. B. Hays. Vorschläge aus den bisherigen Einträgen |
| **Kanal** | Auswahl: auf welchem Weg es kam | | **Kanal** | Auswahl: auf welchem Weg es kam |
| **Projekttitel** | das einzige Pflichtfeld | | **Projekttitel** | das einzige Pflichtfeld |
| **Berater** | Auswahl: 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 | | **Link** | Adresse der Ausschreibung. Ist einer hinterlegt, wird der Titel in der Liste anklickbar und öffnet ihn in einem neuen Tab |
*Von*, *Anbieter*, *Kanal* und *Berater* sind **Auswahllisten**. Ihre Inhalte *Von*, *Kanal* und *Berater* sind **Auswahllisten**. Ihre Inhalte stehen unter
stehen unter *Einstellungen → Akquise* und lassen sich dort von jedem Benutzer *Einstellungen → Akquise* und lassen sich dort von jedem Benutzer pflegen. Ist
pflegen - Freitext hätte aus „Hays", „hays" und „Hays AG" drei Anbieter gemacht. eine Liste noch leer, steht unter dem Feld ein Verweis dorthin.
Ist eine Liste noch leer, steht unter dem Feld ein Verweis dorthin.
**Anbieter** ist bewusst keine Liste, sondern ein Freitextfeld: neue Vermittler
tauchen zu oft auf, als dass das Eintragen daran hängen sollte. Das Feld schlägt
vor, was schon einmal benutzt wurde - der zuletzt verwendete Anbieter zuerst.
Beim Link genügt `hays.de/projekt/1`, das `https://` wird ergänzt. Beim Link genügt `hays.de/projekt/1`, das `https://` wird ergänzt.
@@ -564,13 +567,14 @@ Datenbank.
| --- | --- | --- | | --- | --- | --- |
| **Nutzerverwaltung** | Admin | Benutzer anlegen, bearbeiten, deaktivieren, löschen | | **Nutzerverwaltung** | Admin | Benutzer anlegen, bearbeiten, deaktivieren, löschen |
| **Mein Konto** | alle | Foto, Terminfarbe, Passwort ändern | | **Mein Konto** | alle | Foto, Terminfarbe, Passwort ändern |
| **Akquise** | alle | Inhalte der Auswahllisten *Von*, *Anbieter*, *Kanal*, *Berater* | | **Akquise** | alle | Inhalte der Auswahllisten *Von*, *Kanal*, *Berater* |
| **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktübernahme | | **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktübernahme |
| **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schlüssel, Ton, E-Mail | | **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schlüssel, Ton, E-Mail |
| **Whisper** | alle | Spracherkennung für das Diktat einrichten | | **Whisper** | alle | Spracherkennung für das Diktat einrichten |
Im Reiter **Akquise** stehen die vier Auswahllisten des Akquise-Formulars, jede Im Reiter **Akquise** stehen die drei Auswahllisten des Akquise-Formulars, jede
mit einem Feld zum Hinzufügen und je Eintrag einem Stift und einem Papierkorb. mit einem Feld zum Hinzufügen und je Eintrag einem Stift und einem Papierkorb.
*Anbieter* fehlt hier: das Feld ist Freitext mit Vorschlägen.
Zwei Dinge sind dabei unterschiedlich: Zwei Dinge sind dabei unterschiedlich:
* **Umbenennen** ändert die vorhandenen Akquise-Einträge mit. Aus „Hays" wird in * **Umbenennen** ändert die vorhandenen Akquise-Einträge mit. Aus „Hays" wird in
+6 -2
View File
@@ -28,7 +28,7 @@ Beim Start: `initDatabase()` (CRM-Schema, Admin-Seed) → `initScannerDatabase()
| Export | Zweck | | Export | Zweck |
| --- | --- | | --- | --- |
| `pool` | mysql2-Pool, `connectionLimit: 8`, `dateStrings: true` | | `pool` | mysql2-Pool, `connectionLimit: 8`, `dateStrings: true` |
| `initDatabase()` | führt `schema.sql` Statement für Statement aus, dann `migrateLeadKind()`, `migrateMailPool()`, `ensureAdminUser()` | | `initDatabase()` | führt `schema.sql` Statement für Statement aus, dann `migrateLeadKind()`, `migrateMailPool()`, `seedAcquisitionOptions()`, `dropProviderOptions()`, `ensureAdminUser()` |
`dateStrings: true` ist eine bewusste Entscheidung: Termine kommen als `dateStrings: true` ist eine bewusste Entscheidung: Termine kommen als
`YYYY-MM-DD HH:MM:SS` **ohne** Zeitzonenumrechnung an. Die Terminansicht rechnet mit `YYYY-MM-DD HH:MM:SS` **ohne** Zeitzonenumrechnung an. Die Terminansicht rechnet mit
@@ -52,6 +52,10 @@ Mal etwas:
Einträgen als Freitext steht. Auch hier ein Schalter in `crm_settings` - ohne 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 ihn wäre jeder Neustart ein Wiederherstellen von Werten, die jemand bewusst
entfernt hat. entfernt hat.
* `dropProviderOptions()` nimmt *Anbieter* wieder aus den Auswahllisten heraus -
das Feld ist Freitext mit Vorschlägen. Erkannt am Spaltentyp: steht `provider`
noch im ENUM, ist die Umstellung offen. Die Zeilen müssen vor dem Verengen weg,
sonst weist MariaDB den `ALTER` ab.
`ensureAdminUser()` legt nur an, wenn `crm_users` **leer** ist. Ohne `ensureAdminUser()` legt nur an, wenn `crm_users` **leer** ist. Ohne
`ADMIN_PASSWORD` erzeugt es ein Zufallspasswort und schreibt es einmalig ins Log - `ADMIN_PASSWORD` erzeugt es ein Zufallspasswort und schreibt es einmalig ins Log -
@@ -264,7 +268,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
| `components/acquisition/Acquisitions.tsx` | Akquise und Archiv (`view`); Suche, Sortierung, Übernahme, Löschen | | `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/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/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/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen. *Anbieter* nutzt es nicht: Freitext mit `<datalist>` |
| `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen | | `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen |
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart | | `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/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs |
+1 -1
View File
@@ -22,7 +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. | | **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`. | | **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. | | **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*. | | **Auswahllisten** (Akquise) | Inhalte der Dropdowns *Von*, *Kanal*, *Berater*. Tabelle `crm_acquisition_options`, gepflegt unter *Einstellungen → Akquise*. *Anbieter* ist Freitext mit Vorschlägen. |
| **Art** (eines Kontakts) | Recruiter, Partner oder Endkunde. Spalte `crm_leads.kind`; hieß bis 18.08.2026 „Status" mit den Werten Neu .. Verloren. | | **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. | | **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. | | **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. |
+30 -1
View File
@@ -51,6 +51,7 @@ export async function initDatabase() {
await migrateLeadKind(); await migrateLeadKind();
await migrateMailPool(); await migrateMailPool();
await seedAcquisitionOptions(); await seedAcquisitionOptions();
await dropProviderOptions();
await ensureAdminUser(); await ensureAdminUser();
} }
@@ -151,7 +152,7 @@ async function seedAcquisitionOptions() {
// Vorhandene Freitexte uebernehmen, damit kein Eintrag einen Wert traegt, den // Vorhandene Freitexte uebernehmen, damit kein Eintrag einen Wert traegt, den
// sein Dropdown nicht kennt. // sein Dropdown nicht kennt.
for (const field of ['sender', 'provider', 'consultant', 'channel']) { for (const field of ['sender', 'consultant', 'channel']) {
await pool.query( await pool.query(
`INSERT IGNORE INTO crm_acquisition_options (field, value) `INSERT IGNORE INTO crm_acquisition_options (field, value)
SELECT ?, TRIM(${field}) FROM crm_acquisitions SELECT ?, TRIM(${field}) FROM crm_acquisitions
@@ -171,6 +172,34 @@ async function seedAcquisitionOptions() {
console.log(`[Akquise] Auswahllisten angelegt - ${count.count} Eintrag/Eintraege.`); console.log(`[Akquise] Auswahllisten angelegt - ${count.count} Eintrag/Eintraege.`);
} }
/*
* Einmalige Umstellung: "Anbieter" ist keine Auswahlliste mehr, sondern wieder
* ein Freitextfeld mit Vorschlaegen. Die gepflegten Werte werden nicht mehr
* gebraucht - die Vorschlaege kommen aus den Eintraegen selbst.
*
* Erkannt am Spaltentyp: enthaelt das ENUM noch 'provider', ist die Umstellung
* offen. Die Zeilen muessen vor dem Verengen weg, sonst weist MariaDB den ALTER
* ab. Die Akquise-Eintraege selbst bleiben unberuehrt - ihr Anbieter steht als
* Text in crm_acquisitions, nicht als Verweis.
*/
async function dropProviderOptions() {
const [rows] = await pool.query(
`SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'crm_acquisition_options'
AND COLUMN_NAME = 'field' LIMIT 1`
);
if (!rows[0] || !String(rows[0].COLUMN_TYPE).includes("'provider'")) return;
const [result] = await pool.query("DELETE FROM crm_acquisition_options WHERE field = 'provider'");
await pool.query(
`ALTER TABLE crm_acquisition_options
MODIFY field ENUM('sender','channel','consultant') NOT NULL`
);
console.log(
`[Akquise] Anbieter ist wieder ein Freitextfeld - ${result.affectedRows} Auswahlwert(e) entfernt.`
);
}
async function ensureAdminUser() { async function ensureAdminUser() {
const [rows] = await pool.query('SELECT COUNT(*) AS count FROM crm_users'); const [rows] = await pool.query('SELECT COUNT(*) AS count FROM crm_users');
if (rows[0].count > 0) return; if (rows[0].count > 0) return;
+26 -5
View File
@@ -30,8 +30,11 @@ const DELETE_AFTER_DAYS = 90;
* prueft das Backend beim Speichern eines Eintrags nur die Laenge und nicht die * 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 * Zugehoerigkeit zur Liste: wer einen Wert aus der Liste entfernt, soll damit
* nicht jeden alten Eintrag unaenderbar machen. * nicht jeden alten Eintrag unaenderbar machen.
*
* "provider" (Anbieter) fehlt hier absichtlich: das Feld ist Freitext mit
* Vorschlaegen aus den bisherigen Eintraegen.
*/ */
const OPTION_FIELDS = ['sender', 'provider', 'channel', 'consultant']; const OPTION_FIELDS = ['sender', 'channel', 'consultant'];
// `link` fehlt hier absichtlich: der Wert wird eigens geprueft (normalizeLink). // `link` fehlt hier absichtlich: der Wert wird eigens geprueft (normalizeLink).
const TEXT_FIELDS = [ const TEXT_FIELDS = [
@@ -185,16 +188,32 @@ async function purgeOldEntries() {
} }
} }
/** Alle Auswahllisten als { sender: [...], provider: [...], ... }. */ /** Alle Auswahllisten als { sender: [...], channel: [...], consultant: [...] }. */
async function loadOptions() { async function loadOptions() {
const [rows] = await pool.query( const [rows] = await pool.query(
'SELECT id, field, value FROM crm_acquisition_options ORDER BY field, value' 'SELECT id, field, value FROM crm_acquisition_options ORDER BY field, value'
); );
const options = { sender: [], provider: [], channel: [], consultant: [] }; const options = { sender: [], channel: [], consultant: [] };
for (const row of rows) options[row.field].push({ id: row.id, value: row.value }); for (const row of rows) options[row.field].push({ id: row.id, value: row.value });
return options; return options;
} }
/**
* Bisher benutzte Anbieter, der zuletzt verwendete zuerst. Das Feld ist Freitext -
* die Vorschlaege ersparen das Ausschreiben, ohne einen neuen Vermittler zu
* verbieten. Bewusst aus dem gesamten Bestand, nicht nur aus der gezeigten Liste.
*/
async function loadProviderSuggestions() {
const [rows] = await pool.query(
`SELECT provider FROM crm_acquisitions
WHERE provider IS NOT NULL AND provider <> ''
GROUP BY provider
ORDER BY MAX(found_at) DESC, provider ASC
LIMIT 100`
);
return rows.map((row) => row.provider);
}
/** Aendern und loeschen darf der Ersteller und jeder Admin. */ /** Aendern und loeschen darf der Ersteller und jeder Admin. */
function mayModify(user, row) { function mayModify(user, row) {
return user.role === 'admin' || row.created_by === user.id; return user.role === 'admin' || row.created_by === user.id;
@@ -421,12 +440,14 @@ router.get('/', async (req, res, next) => {
[ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS] [ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS]
); );
// Die Auswahllisten kommen mit: die Eingabe braucht sie sofort, und ein // Auswahllisten und Anbieter-Vorschlaege kommen mit: die Eingabe braucht sie
// zweiter Aufruf nur fuer vier kurze Listen waere verschenkt. // sofort, und ein zweiter Aufruf nur fuer ein paar kurze Listen waere
// verschenkt.
res.json({ res.json({
archive_after_days: ARCHIVE_AFTER_DAYS, archive_after_days: ARCHIVE_AFTER_DAYS,
delete_after_days: DELETE_AFTER_DAYS, delete_after_days: DELETE_AFTER_DAYS,
options: await loadOptions(), options: await loadOptions(),
provider_suggestions: await loadProviderSuggestions(),
counts: { counts: {
eingang: Number(counts.eingang ?? 0), eingang: Number(counts.eingang ?? 0),
archiv: Number(counts.archiv ?? 0), archiv: Number(counts.archiv ?? 0),
+9 -6
View File
@@ -399,20 +399,23 @@ CREATE TABLE IF NOT EXISTS crm_acquisitions (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/* /*
* Auswahllisten der Akquise. "Von", "Anbieter", "Kanal" und "Berater" sind * Auswahllisten der Akquise. "Von", "Kanal" und "Berater" sind Dropdowns, deren
* Dropdowns, deren Inhalte in den Einstellungen gepflegt werden - Freitext hat * Inhalte in den Einstellungen gepflegt werden.
* aus "Hays", "hays" und "Hays AG" drei Anbieter gemacht.
* *
* Bewusst eine Tabelle mit Feldkennzeichen statt vier Tabellen: die Felder * "Anbieter" gehoert ausdruecklich nicht dazu: dort wird frei getippt, mit
* Vorschlaegen aus den bisherigen Eintraegen. Neue Vermittler tauchen zu oft auf,
* als dass sich das Eintragen an einer gepflegten Liste aufhalten sollte.
*
* Bewusst eine Tabelle mit Feldkennzeichen statt drei Tabellen: die Felder
* verhalten sich gleich, und jede weitere Liste ist damit ein Wert im ENUM statt * verhalten sich gleich, und jede weitere Liste ist damit ein Wert im ENUM statt
* einer neuen Tabelle samt Route. * einer neuen Tabelle samt Route.
* *
* Der Unique-Schluessel greift ueber die Kollation utf8mb4_unicode_ci auch bei * Der Unique-Schluessel greift ueber die Kollation utf8mb4_unicode_ci auch bei
* abweichender Gross-/Kleinschreibung: "hays" ist neben "Hays" kein neuer Wert. * abweichender Gross-/Kleinschreibung: "mail" ist neben "Mail" kein neuer Wert.
*/ */
CREATE TABLE IF NOT EXISTS crm_acquisition_options ( CREATE TABLE IF NOT EXISTS crm_acquisition_options (
id INT UNSIGNED NOT NULL AUTO_INCREMENT, id INT UNSIGNED NOT NULL AUTO_INCREMENT,
field ENUM('sender','provider','channel','consultant') NOT NULL, field ENUM('sender','channel','consultant') NOT NULL,
value VARCHAR(190) NOT NULL, value VARCHAR(190) NOT NULL,
created_by INT UNSIGNED NULL, created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+24 -8
View File
@@ -27,6 +27,8 @@ interface AcquisitionFormProps {
editing: Acquisition | null; editing: Acquisition | null;
/** Inhalte der Dropdowns, gepflegt unter Einstellungen -> Akquise. */ /** Inhalte der Dropdowns, gepflegt unter Einstellungen -> Akquise. */
options: AcquisitionOptions; options: AcquisitionOptions;
/** Schon benutzte Anbieter - das Feld bleibt Freitext. */
providerSuggestions: string[];
onSaved: () => void; onSaved: () => void;
onCancelEdit: () => void; onCancelEdit: () => void;
} }
@@ -80,6 +82,7 @@ function toFormState(entry: Acquisition | null): FormState {
export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
editing, editing,
options, options,
providerSuggestions,
onSaved, onSaved,
onCancelEdit, onCancelEdit,
}) => { }) => {
@@ -252,14 +255,27 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
onChange={(value) => set('sender', value)} onChange={(value) => set('sender', value)}
onOpenSettings={openSettings} onOpenSettings={openSettings}
/> />
<OptionSelect {/* Anbieter bleibt Freitext: neue Vermittler tauchen zu oft auf, als dass
id="acq-provider" das Eintragen an einer gepflegten Liste haengen sollte. Die
label={ACQUISITION_FIELD_LABELS.provider} Vorschlagsliste erspart das Ausschreiben bekannter Namen. */}
value={form.provider} <div>
options={options.provider} <label className={labelClass} htmlFor="acq-provider">
onChange={(value) => set('provider', value)} Anbieter
onOpenSettings={openSettings} </label>
/> <input
id="acq-provider"
list="acq-provider-vorschlaege"
value={form.provider}
onChange={(event) => set('provider', event.target.value)}
placeholder="z. B. Hays"
className={fieldClass}
/>
<datalist id="acq-provider-vorschlaege">
{providerSuggestions.map((option) => (
<option key={option} value={option} />
))}
</datalist>
</div>
<OptionSelect <OptionSelect
id="acq-channel" id="acq-channel"
label={ACQUISITION_FIELD_LABELS.channel} label={ACQUISITION_FIELD_LABELS.channel}
@@ -175,6 +175,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
<AcquisitionForm <AcquisitionForm
editing={editing} editing={editing}
options={data?.options ?? EMPTY_ACQUISITION_OPTIONS} options={data?.options ?? EMPTY_ACQUISITION_OPTIONS}
providerSuggestions={data?.provider_suggestions ?? []}
onSaved={() => load(true)} onSaved={() => load(true)}
onCancelEdit={() => setEditing(null)} onCancelEdit={() => setEditing(null)}
/> />
@@ -15,10 +15,13 @@ import {
} from '../../types'; } from '../../types';
/* /*
* Inhalte der Akquise-Dropdowns pflegen: "Von", "Anbieter", "Kanal", "Berater". * Inhalte der Akquise-Dropdowns pflegen: "Von", "Kanal", "Berater".
* *
* Vier gleich aufgebaute Listen statt vier Sonderfaelle - die Felder verhalten * "Anbieter" steht bewusst nicht dabei - das Feld ist Freitext mit Vorschlaegen
* sich gleich, also sieht ihre Pflege auch gleich aus. * aus den bisherigen Eintraegen.
*
* Gleich aufgebaute Listen statt Sonderfaelle - die Felder verhalten sich
* gleich, also sieht ihre Pflege auch gleich aus.
* *
* Umbenennen zieht die vorhandenen Akquise-Eintraege mit, Loeschen nicht: eine * Umbenennen zieht die vorhandenen Akquise-Eintraege mit, Loeschen nicht: eine
* Liste aufzuraeumen darf keine Daten aendern. Beide Antworten sagen, wie viele * Liste aufzuraeumen darf keine Daten aendern. Beide Antworten sagen, wie viele
@@ -133,7 +136,6 @@ export const AcquisitionSettings: React.FC = () => {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [drafts, setDrafts] = useState<Record<AcquisitionOptionField, string>>({ const [drafts, setDrafts] = useState<Record<AcquisitionOptionField, string>>({
sender: '', sender: '',
provider: '',
channel: '', channel: '',
consultant: '', consultant: '',
}); });
@@ -219,10 +221,11 @@ export const AcquisitionSettings: React.FC = () => {
<section className="rounded-2xl border-l-4 border-l-brand-500 border border-ink-200 bg-surface p-5"> <section className="rounded-2xl border-l-4 border-l-brand-500 border border-ink-200 bg-surface p-5">
<h3 className="text-base text-ink-900">Auswahllisten der Akquise</h3> <h3 className="text-base text-ink-900">Auswahllisten der Akquise</h3>
<p className="mt-1 text-xs text-ink-400"> <p className="mt-1 text-xs text-ink-400">
Diese vier Listen füllen die Dropdowns im Formular unter <em>Akquise</em>. Feste Auswahl Diese drei Listen füllen die Dropdowns <em>Von</em>, <em>Kanal</em> und <em>Berater</em> im
statt Freitext, damit aus „Hays", „hays" und „Hays AG" nicht drei Anbieter werden. Formular unter <em>Akquise</em>. Pflegen darf jeder angemeldete Benutzer - wer beim
Pflegen darf jeder angemeldete Benutzer - wer beim Eintragen auf einen neuen Anbieter Eintragen einen fehlenden Wert braucht, soll weiterarbeiten können. Das Feld
stößt, soll weiterarbeiten können. <em> Anbieter</em> steht bewusst nicht hier: dort wird frei getippt, mit Vorschlägen aus
den bisherigen Einträgen.
</p> </p>
<p className="mt-2 text-xs text-ink-400"> <p className="mt-2 text-xs text-ink-400">
<strong>Umbenennen</strong> ändert die vorhandenen Akquise-Einträge mit. <strong>Umbenennen</strong> ändert die vorhandenen Akquise-Einträge mit.
+7 -5
View File
@@ -253,16 +253,18 @@ export type OpportunityViewMode = 'grid' | 'list' | 'pipeline';
/** /**
* Felder der Akquise, die aus einer Auswahlliste kommen. Die Werte stehen in der * Felder der Akquise, die aus einer Auswahlliste kommen. Die Werte stehen in der
* Datenbank (crm_acquisition_options) und werden unter Einstellungen -> Akquise * Datenbank (crm_acquisition_options) und werden unter Einstellungen -> Akquise
* gepflegt - als Freitext waeren "Hays", "hays" und "Hays AG" drei Anbieter. * gepflegt.
*
* "Anbieter" ist ausdruecklich nicht dabei: dort wird frei getippt, mit
* Vorschlaegen aus den bisherigen Eintraegen (`AcquisitionList.provider_suggestions`).
*/ */
export const ACQUISITION_OPTION_FIELDS = ['sender', 'provider', 'channel', 'consultant'] as const; export const ACQUISITION_OPTION_FIELDS = ['sender', 'channel', 'consultant'] as const;
export type AcquisitionOptionField = (typeof ACQUISITION_OPTION_FIELDS)[number]; export type AcquisitionOptionField = (typeof ACQUISITION_OPTION_FIELDS)[number];
/** Beschriftung der Felder - dieselbe in der Liste, im Formular und in den Einstellungen. */ /** Beschriftung der Felder - dieselbe in der Liste, im Formular und in den Einstellungen. */
export const ACQUISITION_FIELD_LABELS: Record<AcquisitionOptionField, string> = { export const ACQUISITION_FIELD_LABELS: Record<AcquisitionOptionField, string> = {
sender: 'Von', sender: 'Von',
provider: 'Anbieter',
channel: 'Kanal', channel: 'Kanal',
consultant: 'Berater', consultant: 'Berater',
}; };
@@ -270,7 +272,6 @@ export const ACQUISITION_FIELD_LABELS: Record<AcquisitionOptionField, string> =
/** Was in der jeweiligen Liste steht - als Hilfe in den Einstellungen. */ /** Was in der jeweiligen Liste steht - als Hilfe in den Einstellungen. */
export const ACQUISITION_FIELD_HINTS: Record<AcquisitionOptionField, string> = { export const ACQUISITION_FIELD_HINTS: Record<AcquisitionOptionField, string> = {
sender: 'Personen, von denen Projekte kommen', sender: 'Personen, von denen Projekte kommen',
provider: 'Vermittler und Portale, z. B. Hays',
channel: 'Auf welchem Weg das Projekt kam', channel: 'Auf welchem Weg das Projekt kam',
consultant: 'Eigene Berater, für die Projekte gesucht werden', consultant: 'Eigene Berater, für die Projekte gesucht werden',
}; };
@@ -284,7 +285,6 @@ export type AcquisitionOptions = Record<AcquisitionOptionField, AcquisitionOptio
export const EMPTY_ACQUISITION_OPTIONS: AcquisitionOptions = { export const EMPTY_ACQUISITION_OPTIONS: AcquisitionOptions = {
sender: [], sender: [],
provider: [],
channel: [], channel: [],
consultant: [], consultant: [],
}; };
@@ -337,6 +337,8 @@ export interface AcquisitionList {
counts: { eingang: number; archiv: number }; counts: { eingang: number; archiv: number };
/** Inhalte der Dropdowns; kommen mit, damit die Eingabe sie sofort hat. */ /** Inhalte der Dropdowns; kommen mit, damit die Eingabe sie sofort hat. */
options: AcquisitionOptions; options: AcquisitionOptions;
/** Schon benutzte Anbieter, der zuletzt verwendete zuerst - "Anbieter" ist Freitext. */
provider_suggestions: string[];
entries: Acquisition[]; entries: Acquisition[];
} }