diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index 812ae70..82b6a01 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -103,6 +103,7 @@ erDiagram int id PK enum field "sender | channel | consultant" varchar value "Inhalt des Dropdowns" + varchar email "nur bei consultant" } crm_acquisition_history { date found_on PK diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index 7697011..366dcde 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -976,6 +976,13 @@ Zwei Dinge sind dabei unterschiedlich: behalten ihn und zeigen ihn im Formular als „nicht in der Liste" - so ändert 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 Hintergrund-Scanner die Börsen durchsucht. diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index eaec804..465ca38 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -56,6 +56,10 @@ Mal etwas: 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. +* `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 `ADMIN_PASSWORD` erzeugt es ein Zufallspasswort und schreibt es einmalig ins Log - diff --git a/server/src/db.js b/server/src/db.js index 5e6c78a..ff723c3 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -55,6 +55,7 @@ export async function initDatabase() { await migrateOpportunityConsultant(); await seedAcquisitionOptions(); await dropProviderOptions(); + await addConsultantEmail(); await migrateIconFlag(); 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 * acquisitionRules.js). VARCHAR(190) reichte fuer einen Namen; drei bis vier diff --git a/server/src/routes/acquisitions.js b/server/src/routes/acquisitions.js index e3eb009..85de5a5 100644 --- a/server/src/routes/acquisitions.js +++ b/server/src/routes/acquisitions.js @@ -295,13 +295,29 @@ async function purgeOldEntries() { /** 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' + 'SELECT id, field, value, email FROM crm_acquisition_options ORDER BY field, value' ); 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; } +/** + * 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 - * 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) { 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 { const [result] = await pool.query( - 'INSERT INTO crm_acquisition_options (field, value, created_by) VALUES (?, ?, ?)', - [field, value, req.user.id] + 'INSERT INTO crm_acquisition_options (field, value, email, created_by) VALUES (?, ?, ?, ?)', + [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) { if (err?.code === 'ER_DUP_ENTRY') { 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); 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(); try { 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] ); const existing = rows[0]; 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; 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 }); + 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.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; if (existing.field === 'consultant') { @@ -528,7 +569,7 @@ router.patch('/options/:id', async (req, res, next) => { } 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) { await conn.rollback().catch(() => {}); if (err?.code === 'ER_DUP_ENTRY') { diff --git a/server/src/schema.sql b/server/src/schema.sql index 958bf1d..9c76d23 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -575,6 +575,7 @@ CREATE TABLE IF NOT EXISTS crm_acquisition_options ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, field ENUM('sender','channel','consultant') NOT NULL, value VARCHAR(190) NOT NULL, + email VARCHAR(190) NULL, created_by INT UNSIGNED NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), diff --git a/src/components/settings/AcquisitionSettings.tsx b/src/components/settings/AcquisitionSettings.tsx index 88b8e67..9896729 100644 --- a/src/components/settings/AcquisitionSettings.tsx +++ b/src/components/settings/AcquisitionSettings.tsx @@ -34,11 +34,63 @@ const PlusIcon = ( ); +/* + * 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; +}> = ({ 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 ( + 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<{ option: AcquisitionOption; onRenamed: (value: string) => Promise; onDelete: () => void; -}> = ({ option, onRenamed, onDelete }) => { + /** Nur bei "Berater" gesetzt - dann zeigt die Zeile das Adressfeld. */ + onEmailSaved?: (email: string) => Promise; +}> = ({ option, onRenamed, onDelete, onEmailSaved }) => { const [isEditing, setIsEditing] = useState(false); const [draft, setDraft] = useState(option.value); const [isSaving, setIsSaving] = useState(false); @@ -66,8 +118,18 @@ const OptionRow: React.FC<{ }; return ( -
  • - {isEditing ? ( +
  • +
    {renderName()}
    + {onEmailSaved && ( +
    + +
    + )} +
  • + ); + + function renderName() { + return isEditing ? ( - )} - - ); + ); + } }; 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 () => { if (!toDelete) return; try { @@ -289,6 +361,9 @@ export const AcquisitionSettings: React.FC = () => { option={option} onRenamed={(value) => rename(option, value)} onDelete={() => setToDelete({ field, option })} + onEmailSaved={ + field === 'consultant' ? (email) => saveEmail(option, email) : undefined + } /> ))} diff --git a/src/lib/api.ts b/src/lib/api.ts index e0861dc..a35c4c3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -490,10 +490,10 @@ export const acquisitionsApi = { export const acquisitionOptionsApi = { list: () => request('/acquisitions/options'), - create: (field: AcquisitionOptionField, value: string) => + create: (field: AcquisitionOptionField, value: string, email?: string | null) => request('/acquisitions/options', { method: 'POST', - body: JSON.stringify({ field, value }), + body: JSON.stringify({ field, value, email }), }), /** Umbenennen zieht die vorhandenen Akquise-Eintraege mit; `updated` sagt wie viele. */ @@ -503,6 +503,13 @@ export const acquisitionOptionsApi = { { method: 'PATCH', body: JSON.stringify({ value }) } ), + /** Nur die E-Mail-Adresse eines Beraters; leer entfernt sie. */ + updateEmail: (id: number, email: string) => + request( + `/acquisitions/options/${id}`, + { method: 'PATCH', body: JSON.stringify({ email }) } + ), + /** Entfernt nur die Auswahl; `still_in_use` zaehlt die Eintraege, die den Wert behalten. */ remove: (id: number) => request<{ ok: true; still_in_use: number }>(`/acquisitions/options/${id}`, { method: 'DELETE' }), diff --git a/src/types.ts b/src/types.ts index c3565e6..b4f1f90 100644 --- a/src/types.ts +++ b/src/types.ts @@ -397,6 +397,8 @@ export const ACQUISITION_FIELD_HINTS: Record = { export interface AcquisitionOption { id: number; value: string; + /** Nur bei "Berater" gepflegt, sonst immer null. */ + email: string | null; } /*