feat(akquise): Auswahllisten statt Freitext, Felder abgesetzt

"Von", "Anbieter", "Kanal" und "Berater" sind Dropdowns. Ihre Inhalte stehen in
crm_acquisition_options und werden im neuen Reiter Einstellungen -> Akquise
gepflegt. Freitext hat aus "Hays", "hays" und "Hays AG" drei Anbieter gemacht;
der Unique-Schluessel greift ueber die Kollation auch bei abweichender
Gross-/Kleinschreibung.

Eine Tabelle mit Feldkennzeichen statt vier Tabellen: die Felder verhalten sich
gleich, also ist jede weitere Liste ein Wert im ENUM statt einer neuen Route und
einer neuen Oberflaeche.

Zwei Entscheidungen dabei:

- Pflegen darf jeder Angemeldete, nicht nur ein Administrator. Eine Liste, die
  nur ein Admin ergaenzen kann, blockiert die Eingabe genau dann, wenn ein neuer
  Anbieter auftaucht.
- Die Eintraege halten den Text, keinen Fremdschluessel. Umbenennen zieht sie
  darum ausdruecklich mit (in einer Transaktion), Loeschen nicht: eine Liste
  aufzuraeumen darf keine Daten aendern. Ein Wert, der nicht mehr in der Liste
  steht, wird im Dropdown weiter angeboten und als solcher gekennzeichnet -
  sonst wuerde das Bearbeiten eines alten Eintrags ihn stillschweigend
  ueberschreiben.

Das Backend prueft beim Speichern eines Eintrags nur noch die Laenge und nicht
mehr die Zugehoerigkeit zur Liste - aus demselben Grund.

Dazu: die Felder der Akquise-Eingabe sind abgesetzt (getoente Flaeche,
deutlicherer Rahmen, beim Hineinklicken weiss). Auf der weissen Karte gingen sie
unter, und im Formular stehen bis zu fuenfzehn davon nebeneinander.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-18 12:13:21 +02:00
co-authored by Claude Opus 5
parent b8a78f609c
commit 05cc261900
14 changed files with 762 additions and 137 deletions
+147 -33
View File
@@ -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),