feat(akquise): E-Mail-Adresse je Berater in den Einstellungen

Neue Spalte crm_acquisition_options.email, pflegbar unter
Einstellungen -> Akquise direkt in der Berater-Zeile. Beim ersten Start
werden vorhandene Berater einmalig mit vorname.nachname@perinco.de
vorbelegt (Merker consultant_email_seeded); Namen ohne Nachnamen bleiben leer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-10-21 09:05:59 +02:00
co-authored by Claude Opus 5.5
parent b9d6544b30
commit 907b010f3c
9 changed files with 223 additions and 19 deletions
+1
View File
@@ -103,6 +103,7 @@ erDiagram
int id PK int id PK
enum field "sender | channel | consultant" enum field "sender | channel | consultant"
varchar value "Inhalt des Dropdowns" varchar value "Inhalt des Dropdowns"
varchar email "nur bei consultant"
} }
crm_acquisition_history { crm_acquisition_history {
date found_on PK date found_on PK
+7
View File
@@ -976,6 +976,13 @@ Zwei Dinge sind dabei unterschiedlich:
behalten ihn und zeigen ihn im Formular als „nicht in der Liste" - so ändert behalten ihn und zeigen ihn im Formular als „nicht in der Liste" - so ändert
das Aufräumen einer Liste keine Daten. das Aufräumen einer Liste keine Daten.
Bei den **Beratern** steht unter jedem Namen zusätzlich ein Feld für die
**E-Mail-Adresse**. Gespeichert wird beim Verlassen des Feldes oder mit Enter;
ein leeres Feld entfernt die Adresse. Beim Einführen des Feldes (21.10.2026)
wurden die vorhandenen Berater einmalig mit `vorname.nachname@perinco.de`
vorbelegt - Umlaute ausgeschrieben, bei mehrteiligen Namen erster und letzter
Teil. Berater mit nur einem Namen blieben leer und brauchen eine Adresse von Hand.
Im Reiter **Scanner** steht das **Suchintervall (Minuten)** - also wie oft der Im Reiter **Scanner** steht das **Suchintervall (Minuten)** - also wie oft der
Hintergrund-Scanner die Börsen durchsucht. Hintergrund-Scanner die Börsen durchsucht.
+4
View File
@@ -56,6 +56,10 @@ Mal etwas:
das Feld ist Freitext mit Vorschlägen. Erkannt am Spaltentyp: steht `provider` 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, noch im ENUM, ist die Umstellung offen. Die Zeilen müssen vor dem Verengen weg,
sonst weist MariaDB den `ALTER` ab. sonst weist MariaDB den `ALTER` ab.
* `addConsultantEmail()` legt `crm_acquisition_options.email` an und belegt die
vorhandenen Berater einmalig mit `vorname.nachname@perinco.de` vor
(`guessPerincoEmail()`, Merker `consultant_email_seeded`). Namen ohne
Nachnamen bleiben leer.
`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 -
+66
View File
@@ -55,6 +55,7 @@ export async function initDatabase() {
await migrateOpportunityConsultant(); await migrateOpportunityConsultant();
await seedAcquisitionOptions(); await seedAcquisitionOptions();
await dropProviderOptions(); await dropProviderOptions();
await addConsultantEmail();
await migrateIconFlag(); await migrateIconFlag();
await ensureAdminUser(); await ensureAdminUser();
} }
@@ -274,6 +275,71 @@ async function dropProviderOptions() {
); );
} }
/*
* E-Mail-Adresse je Berater in der Auswahlliste.
*
* Die Spalte gilt fuer alle Listen, gepflegt wird sie nur bei "Berater" - eine
* eigene Tabelle fuer eine Spalte waere mehr Aufwand als Nutzen.
*
* Beim ersten Anlegen werden die vorhandenen Berater einmalig mit
* vorname.nachname@perinco.de vorbelegt (Umlaute ausgeschrieben). Ein Merker in
* crm_settings haelt das fest - eine bewusst geleerte Adresse soll nach einem
* Neustart nicht wiederkommen. Namen ohne Nachnamen bleiben leer, da laesst
* sich keine Adresse raten.
*/
async function addConsultantEmail() {
const [columns] = await pool.query(
`SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'crm_acquisition_options'
AND COLUMN_NAME = 'email' LIMIT 1`
);
if (!columns[0]) {
await pool.query(
'ALTER TABLE crm_acquisition_options ADD COLUMN email VARCHAR(190) NULL AFTER value'
);
}
const flag = 'consultant_email_seeded';
const [rows] = await pool.query('SELECT value FROM crm_settings WHERE key_name = ? LIMIT 1', [flag]);
if (rows[0]?.value === 'true') return;
const [consultants] = await pool.query(
"SELECT id, value FROM crm_acquisition_options WHERE field = 'consultant' AND email IS NULL"
);
let filled = 0;
for (const row of consultants) {
const email = guessPerincoEmail(row.value);
if (!email) continue;
await pool.query('UPDATE crm_acquisition_options SET email = ? WHERE id = ?', [email, row.id]);
filled += 1;
}
await pool.query(
`INSERT INTO crm_settings (key_name, value) VALUES (?, 'true')
ON DUPLICATE KEY UPDATE value = 'true'`,
[flag]
);
console.log(`[Akquise] Berater-Adressen vorbelegt - ${filled} von ${consultants.length} Berater(n).`);
}
/** "Jörg Müller-Lüdenscheidt" -> "joerg.mueller-luedenscheidt@perinco.de"; ohne Nachname null. */
function guessPerincoEmail(name) {
const parts = String(name)
.trim()
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.split(/\s+/)
.map((part) => part.replace(/[^a-z0-9-]/g, ''))
.filter(Boolean);
if (parts.length < 2) return null;
return `${parts[0]}.${parts[parts.length - 1]}@perinco.de`;
}
/* /*
* Das Feld "Berater" nimmt mehrere Namen auf, kommagetrennt (siehe * Das Feld "Berater" nimmt mehrere Namen auf, kommagetrennt (siehe
* acquisitionRules.js). VARCHAR(190) reichte fuer einen Namen; drei bis vier * acquisitionRules.js). VARCHAR(190) reichte fuer einen Namen; drei bis vier
+52 -11
View File
@@ -295,13 +295,29 @@ async function purgeOldEntries() {
/** Alle Auswahllisten als { sender: [...], channel: [...], consultant: [...] }. */ /** 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, email FROM crm_acquisition_options ORDER BY field, value'
); );
const options = { sender: [], 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, email: row.email ?? null });
}
return options; return options;
} }
/**
* E-Mail-Adresse eines Beraters pruefen. Leer heisst "keine Adresse" (null);
* geprueft wird nur grob die Form - ob das Postfach existiert, weiss hier keiner.
*/
function normalizeOptionEmail(raw) {
const email = emptyToNull(raw);
if (email === null) return { value: null };
if (email.length > 190) return { error: 'Die E-Mail-Adresse ist zu lang (max. 190 Zeichen).' };
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { error: 'Das ist keine gültige E-Mail-Adresse.' };
}
return { value: email.toLowerCase() };
}
/** /**
* Bisher benutzte Anbieter, der zuletzt verwendete zuerst. Das Feld ist Freitext - * Bisher benutzte Anbieter, der zuletzt verwendete zuerst. Das Feld ist Freitext -
* die Vorschlaege ersparen das Ausschreiben, ohne einen neuen Vermittler zu * die Vorschlaege ersparen das Ausschreiben, ohne einen neuen Vermittler zu
@@ -452,13 +468,16 @@ router.post('/options', async (req, res, next) => {
if (value.length > limit) { if (value.length > limit) {
return res.status(400).json({ error: `Der Wert ist zu lang (max. ${limit} Zeichen).` }); return res.status(400).json({ error: `Der Wert ist zu lang (max. ${limit} Zeichen).` });
} }
// Eine Adresse gibt es nur bei Beratern.
const email = field === 'consultant' ? normalizeOptionEmail(req.body?.email) : { value: null };
if (email.error) return res.status(400).json({ error: email.error });
try { try {
const [result] = await pool.query( const [result] = await pool.query(
'INSERT INTO crm_acquisition_options (field, value, created_by) VALUES (?, ?, ?)', 'INSERT INTO crm_acquisition_options (field, value, email, created_by) VALUES (?, ?, ?, ?)',
[field, value, req.user.id] [field, value, email.value, req.user.id]
); );
res.status(201).json({ id: result.insertId, field, value }); res.status(201).json({ id: result.insertId, field, value, email: email.value });
} catch (err) { } catch (err) {
if (err?.code === 'ER_DUP_ENTRY') { if (err?.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'Dieser Wert steht schon in der Liste.' }); return res.status(409).json({ error: 'Dieser Wert steht schon in der Liste.' });
@@ -478,26 +497,48 @@ router.patch('/options/:id', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Options-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.' }); * `value` und `email` sind beide optional: die Einstellungen aendern mal den
* Namen, mal nur die Adresse. Fehlt `value`, bleibt der Name, wie er ist.
*/
const emailGiven = req.body?.email !== undefined;
const email = emailGiven ? normalizeOptionEmail(req.body.email) : null;
if (email?.error) return res.status(400).json({ error: email.error });
const valueGiven = req.body?.value !== undefined;
const requested = valueGiven ? emptyToNull(req.body.value) : null;
if (valueGiven && !requested) return res.status(400).json({ error: 'Ein Wert ist erforderlich.' });
if (!valueGiven && !emailGiven) return res.status(400).json({ error: 'Nichts zu ändern.' });
const conn = await pool.getConnection(); const conn = await pool.getConnection();
try { try {
const [rows] = await conn.query( const [rows] = await conn.query(
'SELECT field, value FROM crm_acquisition_options WHERE id = ? LIMIT 1', 'SELECT field, value, email FROM crm_acquisition_options WHERE id = ? LIMIT 1',
[id] [id]
); );
const existing = rows[0]; const existing = rows[0];
if (!existing) return res.status(404).json({ error: 'Eintrag der Liste nicht gefunden.' }); if (!existing) return res.status(404).json({ error: 'Eintrag der Liste nicht gefunden.' });
if (emailGiven && existing.field !== 'consultant') {
return res.status(400).json({ error: 'Eine E-Mail-Adresse gibt es nur bei Beratern.' });
}
const value = requested ?? existing.value;
const newEmail = emailGiven ? email.value : existing.email ?? null;
const limit = existing.field === 'channel' ? 60 : 190; const limit = existing.field === 'channel' ? 60 : 190;
if (value.length > limit) { if (value.length > limit) {
return res.status(400).json({ error: `Der Wert ist zu lang (max. ${limit} Zeichen).` }); 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 }); if (value === existing.value) {
await conn.query('UPDATE crm_acquisition_options SET email = ? WHERE id = ?', [newEmail, id]);
return res.json({ id, field: existing.field, value, email: newEmail, updated: 0 });
}
await conn.beginTransaction(); await conn.beginTransaction();
await conn.query('UPDATE crm_acquisition_options SET value = ? WHERE id = ?', [value, id]); await conn.query('UPDATE crm_acquisition_options SET value = ?, email = ? WHERE id = ?', [
value,
newEmail,
id,
]);
let touched; let touched;
if (existing.field === 'consultant') { if (existing.field === 'consultant') {
@@ -528,7 +569,7 @@ router.patch('/options/:id', async (req, res, next) => {
} }
await conn.commit(); await conn.commit();
res.json({ id, field: existing.field, value, updated: touched }); res.json({ id, field: existing.field, value, email: newEmail, updated: touched });
} catch (err) { } catch (err) {
await conn.rollback().catch(() => {}); await conn.rollback().catch(() => {});
if (err?.code === 'ER_DUP_ENTRY') { if (err?.code === 'ER_DUP_ENTRY') {
+1
View File
@@ -575,6 +575,7 @@ 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','channel','consultant') NOT NULL, field ENUM('sender','channel','consultant') NOT NULL,
value VARCHAR(190) NOT NULL, value VARCHAR(190) NOT NULL,
email VARCHAR(190) 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,
PRIMARY KEY (id), PRIMARY KEY (id),
@@ -34,11 +34,63 @@ const PlusIcon = (
</svg> </svg>
); );
/*
* E-Mail-Adresse eines Beraters, direkt in der Zeile bearbeitbar. Gespeichert
* wird beim Verlassen des Feldes oder mit Enter - wie beim Umbenennen, nur ohne
* extra Knopf, weil die Adresse oefter nachgetragen als geaendert wird.
*/
const EmailField: React.FC<{
option: AcquisitionOption;
onSave: (email: string) => Promise<void>;
}> = ({ option, onSave }) => {
const [draft, setDraft] = useState(option.email ?? '');
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
setDraft(option.email ?? '');
}, [option.email]);
const save = async () => {
const email = draft.trim();
if (email === (option.email ?? '')) return;
setIsSaving(true);
try {
await onSave(email);
} catch {
setDraft(option.email ?? '');
} finally {
setIsSaving(false);
}
};
return (
<input
type="email"
value={draft}
disabled={isSaving}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === 'Escape') setDraft(option.email ?? '');
}}
onBlur={save}
placeholder="E-Mail-Adresse"
aria-label={`E-Mail-Adresse von ${option.value}`}
className={`${fieldClass} py-1.5 text-xs`}
/>
);
};
const OptionRow: React.FC<{ const OptionRow: React.FC<{
option: AcquisitionOption; option: AcquisitionOption;
onRenamed: (value: string) => Promise<void>; onRenamed: (value: string) => Promise<void>;
onDelete: () => void; onDelete: () => void;
}> = ({ option, onRenamed, onDelete }) => { /** Nur bei "Berater" gesetzt - dann zeigt die Zeile das Adressfeld. */
onEmailSaved?: (email: string) => Promise<void>;
}> = ({ option, onRenamed, onDelete, onEmailSaved }) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(option.value); const [draft, setDraft] = useState(option.value);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -66,8 +118,18 @@ const OptionRow: React.FC<{
}; };
return ( return (
<li className="flex items-center gap-2 rounded-xl border border-ink-200 bg-surface px-3 py-2"> <li className="rounded-xl border border-ink-200 bg-surface px-3 py-2">
{isEditing ? ( <div className="flex items-center gap-2">{renderName()}</div>
{onEmailSaved && (
<div className="mt-1.5">
<EmailField option={option} onSave={onEmailSaved} />
</div>
)}
</li>
);
function renderName() {
return isEditing ? (
<input <input
autoFocus autoFocus
value={draft} value={draft}
@@ -125,9 +187,8 @@ const OptionRow: React.FC<{
</svg> </svg>
</button> </button>
</> </>
)}
</li>
); );
}
}; };
export const AcquisitionSettings: React.FC = () => { export const AcquisitionSettings: React.FC = () => {
@@ -190,6 +251,17 @@ export const AcquisitionSettings: React.FC = () => {
} }
}; };
const saveEmail = async (option: AcquisitionOption, email: string) => {
try {
await acquisitionOptionsApi.updateEmail(option.id, email);
addToast(email ? `Adresse für ${option.value} gespeichert.` : 'Adresse entfernt.', 'success');
await load(true);
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
throw err;
}
};
const confirmDelete = async () => { const confirmDelete = async () => {
if (!toDelete) return; if (!toDelete) return;
try { try {
@@ -289,6 +361,9 @@ export const AcquisitionSettings: React.FC = () => {
option={option} option={option}
onRenamed={(value) => rename(option, value)} onRenamed={(value) => rename(option, value)}
onDelete={() => setToDelete({ field, option })} onDelete={() => setToDelete({ field, option })}
onEmailSaved={
field === 'consultant' ? (email) => saveEmail(option, email) : undefined
}
/> />
))} ))}
</ul> </ul>
+9 -2
View File
@@ -490,10 +490,10 @@ export const acquisitionsApi = {
export const acquisitionOptionsApi = { export const acquisitionOptionsApi = {
list: () => request<AcquisitionOptions>('/acquisitions/options'), list: () => request<AcquisitionOptions>('/acquisitions/options'),
create: (field: AcquisitionOptionField, value: string) => create: (field: AcquisitionOptionField, value: string, email?: string | null) =>
request<AcquisitionOption & { field: AcquisitionOptionField }>('/acquisitions/options', { request<AcquisitionOption & { field: AcquisitionOptionField }>('/acquisitions/options', {
method: 'POST', method: 'POST',
body: JSON.stringify({ field, value }), body: JSON.stringify({ field, value, email }),
}), }),
/** Umbenennen zieht die vorhandenen Akquise-Eintraege mit; `updated` sagt wie viele. */ /** Umbenennen zieht die vorhandenen Akquise-Eintraege mit; `updated` sagt wie viele. */
@@ -503,6 +503,13 @@ export const acquisitionOptionsApi = {
{ method: 'PATCH', body: JSON.stringify({ value }) } { method: 'PATCH', body: JSON.stringify({ value }) }
), ),
/** Nur die E-Mail-Adresse eines Beraters; leer entfernt sie. */
updateEmail: (id: number, email: string) =>
request<AcquisitionOption & { field: AcquisitionOptionField; updated: number }>(
`/acquisitions/options/${id}`,
{ method: 'PATCH', body: JSON.stringify({ email }) }
),
/** Entfernt nur die Auswahl; `still_in_use` zaehlt die Eintraege, die den Wert behalten. */ /** Entfernt nur die Auswahl; `still_in_use` zaehlt die Eintraege, die den Wert behalten. */
remove: (id: number) => remove: (id: number) =>
request<{ ok: true; still_in_use: number }>(`/acquisitions/options/${id}`, { method: 'DELETE' }), request<{ ok: true; still_in_use: number }>(`/acquisitions/options/${id}`, { method: 'DELETE' }),
+2
View File
@@ -397,6 +397,8 @@ export const ACQUISITION_FIELD_HINTS: Record<AcquisitionOptionField, string> = {
export interface AcquisitionOption { export interface AcquisitionOption {
id: number; id: number;
value: string; value: string;
/** Nur bei "Berater" gepflegt, sonst immer null. */
email: string | null;
} }
/* /*