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:
co-authored by
Claude Opus 5
parent
b8a78f609c
commit
05cc261900
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user