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
+30 -1
View File
@@ -51,6 +51,7 @@ export async function initDatabase() {
await migrateLeadKind();
await migrateMailPool();
await seedAcquisitionOptions();
await dropProviderOptions();
await ensureAdminUser();
}
@@ -151,7 +152,7 @@ async function seedAcquisitionOptions() {
// Vorhandene Freitexte uebernehmen, damit kein Eintrag einen Wert traegt, den
// sein Dropdown nicht kennt.
for (const field of ['sender', 'provider', 'consultant', 'channel']) {
for (const field of ['sender', 'consultant', 'channel']) {
await pool.query(
`INSERT IGNORE INTO crm_acquisition_options (field, value)
SELECT ?, TRIM(${field}) FROM crm_acquisitions
@@ -171,6 +172,34 @@ async function seedAcquisitionOptions() {
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() {
const [rows] = await pool.query('SELECT COUNT(*) AS count FROM crm_users');
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
* Zugehoerigkeit zur Liste: wer einen Wert aus der Liste entfernt, soll damit
* 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).
const TEXT_FIELDS = [
@@ -185,16 +188,32 @@ async function purgeOldEntries() {
}
}
/** Alle Auswahllisten als { sender: [...], provider: [...], ... }. */
/** Alle Auswahllisten als { sender: [...], channel: [...], consultant: [...] }. */
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: [] };
const options = { sender: [], channel: [], consultant: [] };
for (const row of rows) options[row.field].push({ id: row.id, value: row.value });
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. */
function mayModify(user, row) {
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]
);
// Die Auswahllisten kommen mit: die Eingabe braucht sie sofort, und ein
// zweiter Aufruf nur fuer vier kurze Listen waere verschenkt.
// Auswahllisten und Anbieter-Vorschlaege kommen mit: die Eingabe braucht sie
// sofort, und ein zweiter Aufruf nur fuer ein paar kurze Listen waere
// verschenkt.
res.json({
archive_after_days: ARCHIVE_AFTER_DAYS,
delete_after_days: DELETE_AFTER_DAYS,
options: await loadOptions(),
provider_suggestions: await loadProviderSuggestions(),
counts: {
eingang: Number(counts.eingang ?? 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;
/*
* 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.
* Auswahllisten der Akquise. "Von", "Kanal" und "Berater" sind Dropdowns, deren
* Inhalte in den Einstellungen gepflegt werden.
*
* 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
* 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.
* abweichender Gross-/Kleinschreibung: "mail" ist neben "Mail" 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,
field ENUM('sender','channel','consultant') NOT NULL,
value VARCHAR(190) NOT NULL,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,