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:
co-authored by
Claude Opus 5
parent
05cc261900
commit
23087a12fb
@@ -25,7 +25,7 @@ erDiagram
|
|||||||
crm_opportunities ||--o{ crm_opportunity_contacts : hat
|
crm_opportunities ||--o{ crm_opportunity_contacts : hat
|
||||||
crm_users ||--o{ crm_acquisitions : created_by
|
crm_users ||--o{ crm_acquisitions : created_by
|
||||||
crm_acquisitions |o--o| crm_opportunities : "wird zu"
|
crm_acquisitions |o--o| crm_opportunities : "wird zu"
|
||||||
crm_acquisition_options ||--o{ crm_acquisitions : "fuellt Von, Anbieter, Kanal, Berater"
|
crm_acquisition_options ||--o{ crm_acquisitions : "fuellt Von, Kanal, Berater"
|
||||||
|
|
||||||
crm_users {
|
crm_users {
|
||||||
int id PK
|
int id PK
|
||||||
@@ -91,7 +91,7 @@ erDiagram
|
|||||||
}
|
}
|
||||||
crm_acquisition_options {
|
crm_acquisition_options {
|
||||||
int id PK
|
int id PK
|
||||||
enum field "sender | provider | channel | consultant"
|
enum field "sender | channel | consultant"
|
||||||
varchar value "Inhalt des Dropdowns"
|
varchar value "Inhalt des Dropdowns"
|
||||||
}
|
}
|
||||||
crm_events {
|
crm_events {
|
||||||
@@ -126,14 +126,16 @@ Sechs Eigenheiten, die man kennen muss:
|
|||||||
- **`avatar` ist eine Data-URL in der Datenbank.** Der Browser verkleinert jedes
|
- **`avatar` ist eine Data-URL in der Datenbank.** Der Browser verkleinert jedes
|
||||||
Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden
|
Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden
|
||||||
wenige Kilobyte.
|
wenige Kilobyte.
|
||||||
- **Die Dropdowns der Akquise stehen in einer Tabelle, nicht in vier.**
|
- **Die Dropdowns der Akquise stehen in einer Tabelle, nicht in drei.**
|
||||||
`crm_acquisition_options` unterscheidet die Listen über die Spalte `field`. Die
|
`crm_acquisition_options` unterscheidet die Listen über die Spalte `field`. Die
|
||||||
vier Felder verhalten sich gleich, also ist jede weitere Liste ein Wert im
|
Felder verhalten sich gleich, also ist jede weitere Liste ein Wert im ENUM
|
||||||
ENUM statt einer neuen Tabelle samt Route und Oberfläche. Der Unique-Schlüssel
|
statt einer neuen Tabelle samt Route und Oberfläche. Der Unique-Schlüssel auf
|
||||||
auf `(field, value)` greift über die Kollation `utf8mb4_unicode_ci` auch bei
|
`(field, value)` greift über die Kollation `utf8mb4_unicode_ci` auch bei
|
||||||
abweichender Groß-/Kleinschreibung - „hays" ist neben „Hays" kein neuer Wert.
|
abweichender Groß-/Kleinschreibung - „mail" ist neben „Mail" kein neuer Wert.
|
||||||
Die Akquise-Einträge halten den **Text**, keinen Fremdschlüssel: wird ein Wert
|
Die Akquise-Einträge halten den **Text**, keinen Fremdschlüssel: wird ein Wert
|
||||||
aus der Liste entfernt, bleiben die Einträge lesbar und änderbar.
|
aus der Liste entfernt, bleiben die Einträge lesbar und änderbar.
|
||||||
|
`crm_acquisitions.provider` (Anbieter) gehört nicht dazu - das Feld ist
|
||||||
|
Freitext, seine Vorschläge kommen aus den vorhandenen Einträgen.
|
||||||
- **`crm_acquisitions` hat keine Zustandsspalte.** Eingang, Archiv und Löschung
|
- **`crm_acquisitions` hat keine Zustandsspalte.** Eingang, Archiv und Löschung
|
||||||
ergeben sich aus `found_at` (14 bzw. 90 Tage) und `converted_at`. Eine Spalte,
|
ergeben sich aus `found_at` (14 bzw. 90 Tage) und `converted_at`. Eine Spalte,
|
||||||
die ein Job umsetzen müsste, kann von der Wirklichkeit abweichen - eine
|
die ein Job umsetzen müsste, kann von der Wirklichkeit abweichen - eine
|
||||||
|
|||||||
@@ -89,16 +89,19 @@ weg.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Datum** | wann gefunden. Vorbelegt mit heute; entscheidet später über Archiv und Löschung |
|
| **Datum** | wann gefunden. Vorbelegt mit heute; entscheidet später über Archiv und Löschung |
|
||||||
| **Von** | Auswahl: wer es geschickt hat |
|
| **Von** | Auswahl: wer es geschickt hat |
|
||||||
| **Anbieter** | Auswahl: Vermittler oder Portal, z. B. Hays |
|
| **Anbieter** | Freitext: Vermittler oder Portal, z. B. Hays. Vorschläge aus den bisherigen Einträgen |
|
||||||
| **Kanal** | Auswahl: auf welchem Weg es kam |
|
| **Kanal** | Auswahl: auf welchem Weg es kam |
|
||||||
| **Projekttitel** | das einzige Pflichtfeld |
|
| **Projekttitel** | das einzige Pflichtfeld |
|
||||||
| **Berater** | Auswahl: für wen das Projekt passt |
|
| **Berater** | Auswahl: für wen das Projekt passt |
|
||||||
| **Link** | Adresse der Ausschreibung. Ist einer hinterlegt, wird der Titel in der Liste anklickbar und öffnet ihn in einem neuen Tab |
|
| **Link** | Adresse der Ausschreibung. Ist einer hinterlegt, wird der Titel in der Liste anklickbar und öffnet ihn in einem neuen Tab |
|
||||||
|
|
||||||
*Von*, *Anbieter*, *Kanal* und *Berater* sind **Auswahllisten**. Ihre Inhalte
|
*Von*, *Kanal* und *Berater* sind **Auswahllisten**. Ihre Inhalte stehen unter
|
||||||
stehen unter *Einstellungen → Akquise* und lassen sich dort von jedem Benutzer
|
*Einstellungen → Akquise* und lassen sich dort von jedem Benutzer pflegen. Ist
|
||||||
pflegen - Freitext hätte aus „Hays", „hays" und „Hays AG" drei Anbieter gemacht.
|
eine Liste noch leer, steht unter dem Feld ein Verweis dorthin.
|
||||||
Ist eine Liste noch leer, steht unter dem Feld ein Verweis dorthin.
|
|
||||||
|
**Anbieter** ist bewusst keine Liste, sondern ein Freitextfeld: neue Vermittler
|
||||||
|
tauchen zu oft auf, als dass das Eintragen daran hängen sollte. Das Feld schlägt
|
||||||
|
vor, was schon einmal benutzt wurde - der zuletzt verwendete Anbieter zuerst.
|
||||||
|
|
||||||
Beim Link genügt `hays.de/projekt/1`, das `https://` wird ergänzt.
|
Beim Link genügt `hays.de/projekt/1`, das `https://` wird ergänzt.
|
||||||
|
|
||||||
@@ -564,13 +567,14 @@ Datenbank.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **Nutzerverwaltung** | Admin | Benutzer anlegen, bearbeiten, deaktivieren, löschen |
|
| **Nutzerverwaltung** | Admin | Benutzer anlegen, bearbeiten, deaktivieren, löschen |
|
||||||
| **Mein Konto** | alle | Foto, Terminfarbe, Passwort ändern |
|
| **Mein Konto** | alle | Foto, Terminfarbe, Passwort ändern |
|
||||||
| **Akquise** | alle | Inhalte der Auswahllisten *Von*, *Anbieter*, *Kanal*, *Berater* |
|
| **Akquise** | alle | Inhalte der Auswahllisten *Von*, *Kanal*, *Berater* |
|
||||||
| **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktübernahme |
|
| **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktübernahme |
|
||||||
| **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schlüssel, Ton, E-Mail |
|
| **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schlüssel, Ton, E-Mail |
|
||||||
| **Whisper** | alle | Spracherkennung für das Diktat einrichten |
|
| **Whisper** | alle | Spracherkennung für das Diktat einrichten |
|
||||||
|
|
||||||
Im Reiter **Akquise** stehen die vier Auswahllisten des Akquise-Formulars, jede
|
Im Reiter **Akquise** stehen die drei Auswahllisten des Akquise-Formulars, jede
|
||||||
mit einem Feld zum Hinzufügen und je Eintrag einem Stift und einem Papierkorb.
|
mit einem Feld zum Hinzufügen und je Eintrag einem Stift und einem Papierkorb.
|
||||||
|
*Anbieter* fehlt hier: das Feld ist Freitext mit Vorschlägen.
|
||||||
Zwei Dinge sind dabei unterschiedlich:
|
Zwei Dinge sind dabei unterschiedlich:
|
||||||
|
|
||||||
* **Umbenennen** ändert die vorhandenen Akquise-Einträge mit. Aus „Hays" wird in
|
* **Umbenennen** ändert die vorhandenen Akquise-Einträge mit. Aus „Hays" wird in
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Beim Start: `initDatabase()` (CRM-Schema, Admin-Seed) → `initScannerDatabase()
|
|||||||
| Export | Zweck |
|
| Export | Zweck |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `pool` | mysql2-Pool, `connectionLimit: 8`, `dateStrings: true` |
|
| `pool` | mysql2-Pool, `connectionLimit: 8`, `dateStrings: true` |
|
||||||
| `initDatabase()` | führt `schema.sql` Statement für Statement aus, dann `migrateLeadKind()`, `migrateMailPool()`, `ensureAdminUser()` |
|
| `initDatabase()` | führt `schema.sql` Statement für Statement aus, dann `migrateLeadKind()`, `migrateMailPool()`, `seedAcquisitionOptions()`, `dropProviderOptions()`, `ensureAdminUser()` |
|
||||||
|
|
||||||
`dateStrings: true` ist eine bewusste Entscheidung: Termine kommen als
|
`dateStrings: true` ist eine bewusste Entscheidung: Termine kommen als
|
||||||
`YYYY-MM-DD HH:MM:SS` **ohne** Zeitzonenumrechnung an. Die Terminansicht rechnet mit
|
`YYYY-MM-DD HH:MM:SS` **ohne** Zeitzonenumrechnung an. Die Terminansicht rechnet mit
|
||||||
@@ -52,6 +52,10 @@ Mal etwas:
|
|||||||
Einträgen als Freitext steht. Auch hier ein Schalter in `crm_settings` - ohne
|
Einträgen als Freitext steht. Auch hier ein Schalter in `crm_settings` - ohne
|
||||||
ihn wäre jeder Neustart ein Wiederherstellen von Werten, die jemand bewusst
|
ihn wäre jeder Neustart ein Wiederherstellen von Werten, die jemand bewusst
|
||||||
entfernt hat.
|
entfernt hat.
|
||||||
|
* `dropProviderOptions()` nimmt *Anbieter* wieder aus den Auswahllisten heraus -
|
||||||
|
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.
|
||||||
|
|
||||||
`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 -
|
||||||
@@ -264,7 +268,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
|
|||||||
| `components/acquisition/Acquisitions.tsx` | Akquise und Archiv (`view`); Suche, Sortierung, Übernahme, Löschen |
|
| `components/acquisition/Acquisitions.tsx` | Akquise und Archiv (`view`); Suche, Sortierung, Übernahme, Löschen |
|
||||||
| `components/acquisition/AcquisitionForm.tsx` | Eingabe über der Liste; behält Datum, Anbieter, Kanal und Berater für den nächsten Eintrag |
|
| `components/acquisition/AcquisitionForm.tsx` | Eingabe über der Liste; behält Datum, Anbieter, Kanal und Berater für den nächsten Eintrag |
|
||||||
| `components/acquisition/AcquisitionTable.tsx` | die Liste; `safeLink()` lässt nur http(s) zum Link werden |
|
| `components/acquisition/AcquisitionTable.tsx` | die Liste; `safeLink()` lässt nur http(s) zum Link werden |
|
||||||
| `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen |
|
| `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen. *Anbieter* nutzt es nicht: Freitext mit `<datalist>` |
|
||||||
| `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen |
|
| `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen |
|
||||||
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart |
|
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart |
|
||||||
| `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs |
|
| `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs |
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ Quelltext.
|
|||||||
| **Match** / Treffer | Die Bewertung **eines** Projekts für **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. |
|
| **Match** / Treffer | Die Bewertung **eines** Projekts für **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. |
|
||||||
| **Opportunity** | Geschäft in Arbeit: Phase, Tagessatz und Laufzeit oder Projektpreis, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. |
|
| **Opportunity** | Geschäft in Arbeit: Phase, Tagessatz und Laufzeit oder Projektpreis, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. |
|
||||||
| **Akquise** | Eingang für gefundene Projekte, bevor daraus eine Opportunity wird. Tabelle `crm_acquisitions`. Nach 14 Tagen im **Archiv**, nach 90 Tagen gelöscht. |
|
| **Akquise** | Eingang für gefundene Projekte, bevor daraus eine Opportunity wird. Tabelle `crm_acquisitions`. Nach 14 Tagen im **Archiv**, nach 90 Tagen gelöscht. |
|
||||||
| **Auswahllisten** (Akquise) | Inhalte der Dropdowns *Von*, *Anbieter*, *Kanal*, *Berater*. Tabelle `crm_acquisition_options`, gepflegt unter *Einstellungen → Akquise*. |
|
| **Auswahllisten** (Akquise) | Inhalte der Dropdowns *Von*, *Kanal*, *Berater*. Tabelle `crm_acquisition_options`, gepflegt unter *Einstellungen → Akquise*. *Anbieter* ist Freitext mit Vorschlägen. |
|
||||||
| **Art** (eines Kontakts) | Recruiter, Partner oder Endkunde. Spalte `crm_leads.kind`; hieß bis 18.08.2026 „Status" mit den Werten Neu .. Verloren. |
|
| **Art** (eines Kontakts) | Recruiter, Partner oder Endkunde. Spalte `crm_leads.kind`; hieß bis 18.08.2026 „Status" mit den Werten Neu .. Verloren. |
|
||||||
| **Papierkorb** | Im CRM gelöschte Mails. Umkehrbar, bis er geleert wird. Im Postfach crm@perinco.de bleiben die Mails in jedem Fall; der Abgleich holt gelöschte nicht wieder herein. |
|
| **Papierkorb** | Im CRM gelöschte Mails. Umkehrbar, bis er geleert wird. Im Postfach crm@perinco.de bleiben die Mails in jedem Fall; der Abgleich holt gelöschte nicht wieder herein. |
|
||||||
| **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. |
|
| **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. |
|
||||||
|
|||||||
+30
-1
@@ -51,6 +51,7 @@ export async function initDatabase() {
|
|||||||
await migrateLeadKind();
|
await migrateLeadKind();
|
||||||
await migrateMailPool();
|
await migrateMailPool();
|
||||||
await seedAcquisitionOptions();
|
await seedAcquisitionOptions();
|
||||||
|
await dropProviderOptions();
|
||||||
await ensureAdminUser();
|
await ensureAdminUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +152,7 @@ async function seedAcquisitionOptions() {
|
|||||||
|
|
||||||
// Vorhandene Freitexte uebernehmen, damit kein Eintrag einen Wert traegt, den
|
// Vorhandene Freitexte uebernehmen, damit kein Eintrag einen Wert traegt, den
|
||||||
// sein Dropdown nicht kennt.
|
// sein Dropdown nicht kennt.
|
||||||
for (const field of ['sender', 'provider', 'consultant', 'channel']) {
|
for (const field of ['sender', 'consultant', 'channel']) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT IGNORE INTO crm_acquisition_options (field, value)
|
`INSERT IGNORE INTO crm_acquisition_options (field, value)
|
||||||
SELECT ?, TRIM(${field}) FROM crm_acquisitions
|
SELECT ?, TRIM(${field}) FROM crm_acquisitions
|
||||||
@@ -171,6 +172,34 @@ async function seedAcquisitionOptions() {
|
|||||||
console.log(`[Akquise] Auswahllisten angelegt - ${count.count} Eintrag/Eintraege.`);
|
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() {
|
async function ensureAdminUser() {
|
||||||
const [rows] = await pool.query('SELECT COUNT(*) AS count FROM crm_users');
|
const [rows] = await pool.query('SELECT COUNT(*) AS count FROM crm_users');
|
||||||
if (rows[0].count > 0) return;
|
if (rows[0].count > 0) return;
|
||||||
|
|||||||
@@ -30,8 +30,11 @@ const DELETE_AFTER_DAYS = 90;
|
|||||||
* prueft das Backend beim Speichern eines Eintrags nur die Laenge und nicht die
|
* 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
|
* Zugehoerigkeit zur Liste: wer einen Wert aus der Liste entfernt, soll damit
|
||||||
* nicht jeden alten Eintrag unaenderbar machen.
|
* 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).
|
// `link` fehlt hier absichtlich: der Wert wird eigens geprueft (normalizeLink).
|
||||||
const TEXT_FIELDS = [
|
const TEXT_FIELDS = [
|
||||||
@@ -185,16 +188,32 @@ async function purgeOldEntries() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Alle Auswahllisten als { sender: [...], provider: [...], ... }. */
|
/** 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 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 });
|
for (const row of rows) options[row.field].push({ id: row.id, value: row.value });
|
||||||
return options;
|
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. */
|
/** Aendern und loeschen darf der Ersteller und jeder Admin. */
|
||||||
function mayModify(user, row) {
|
function mayModify(user, row) {
|
||||||
return user.role === 'admin' || row.created_by === user.id;
|
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]
|
[ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Die Auswahllisten kommen mit: die Eingabe braucht sie sofort, und ein
|
// Auswahllisten und Anbieter-Vorschlaege kommen mit: die Eingabe braucht sie
|
||||||
// zweiter Aufruf nur fuer vier kurze Listen waere verschenkt.
|
// sofort, und ein zweiter Aufruf nur fuer ein paar kurze Listen waere
|
||||||
|
// verschenkt.
|
||||||
res.json({
|
res.json({
|
||||||
archive_after_days: ARCHIVE_AFTER_DAYS,
|
archive_after_days: ARCHIVE_AFTER_DAYS,
|
||||||
delete_after_days: DELETE_AFTER_DAYS,
|
delete_after_days: DELETE_AFTER_DAYS,
|
||||||
options: await loadOptions(),
|
options: await loadOptions(),
|
||||||
|
provider_suggestions: await loadProviderSuggestions(),
|
||||||
counts: {
|
counts: {
|
||||||
eingang: Number(counts.eingang ?? 0),
|
eingang: Number(counts.eingang ?? 0),
|
||||||
archiv: Number(counts.archiv ?? 0),
|
archiv: Number(counts.archiv ?? 0),
|
||||||
|
|||||||
@@ -399,20 +399,23 @@ CREATE TABLE IF NOT EXISTS crm_acquisitions (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Auswahllisten der Akquise. "Von", "Anbieter", "Kanal" und "Berater" sind
|
* Auswahllisten der Akquise. "Von", "Kanal" und "Berater" sind Dropdowns, deren
|
||||||
* Dropdowns, deren Inhalte in den Einstellungen gepflegt werden - Freitext hat
|
* Inhalte in den Einstellungen gepflegt werden.
|
||||||
* aus "Hays", "hays" und "Hays AG" drei Anbieter gemacht.
|
|
||||||
*
|
*
|
||||||
* 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
|
* verhalten sich gleich, und jede weitere Liste ist damit ein Wert im ENUM statt
|
||||||
* einer neuen Tabelle samt Route.
|
* einer neuen Tabelle samt Route.
|
||||||
*
|
*
|
||||||
* Der Unique-Schluessel greift ueber die Kollation utf8mb4_unicode_ci auch bei
|
* 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 (
|
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','provider','channel','consultant') NOT NULL,
|
field ENUM('sender','channel','consultant') NOT NULL,
|
||||||
value VARCHAR(190) NOT NULL,
|
value VARCHAR(190) NOT 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,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface AcquisitionFormProps {
|
|||||||
editing: Acquisition | null;
|
editing: Acquisition | null;
|
||||||
/** Inhalte der Dropdowns, gepflegt unter Einstellungen -> Akquise. */
|
/** Inhalte der Dropdowns, gepflegt unter Einstellungen -> Akquise. */
|
||||||
options: AcquisitionOptions;
|
options: AcquisitionOptions;
|
||||||
|
/** Schon benutzte Anbieter - das Feld bleibt Freitext. */
|
||||||
|
providerSuggestions: string[];
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
onCancelEdit: () => void;
|
onCancelEdit: () => void;
|
||||||
}
|
}
|
||||||
@@ -80,6 +82,7 @@ function toFormState(entry: Acquisition | null): FormState {
|
|||||||
export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
||||||
editing,
|
editing,
|
||||||
options,
|
options,
|
||||||
|
providerSuggestions,
|
||||||
onSaved,
|
onSaved,
|
||||||
onCancelEdit,
|
onCancelEdit,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -252,14 +255,27 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
|||||||
onChange={(value) => set('sender', value)}
|
onChange={(value) => set('sender', value)}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
/>
|
/>
|
||||||
<OptionSelect
|
{/* Anbieter bleibt Freitext: neue Vermittler tauchen zu oft auf, als dass
|
||||||
|
das Eintragen an einer gepflegten Liste haengen sollte. Die
|
||||||
|
Vorschlagsliste erspart das Ausschreiben bekannter Namen. */}
|
||||||
|
<div>
|
||||||
|
<label className={labelClass} htmlFor="acq-provider">
|
||||||
|
Anbieter
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
id="acq-provider"
|
id="acq-provider"
|
||||||
label={ACQUISITION_FIELD_LABELS.provider}
|
list="acq-provider-vorschlaege"
|
||||||
value={form.provider}
|
value={form.provider}
|
||||||
options={options.provider}
|
onChange={(event) => set('provider', event.target.value)}
|
||||||
onChange={(value) => set('provider', value)}
|
placeholder="z. B. Hays"
|
||||||
onOpenSettings={openSettings}
|
className={fieldClass}
|
||||||
/>
|
/>
|
||||||
|
<datalist id="acq-provider-vorschlaege">
|
||||||
|
{providerSuggestions.map((option) => (
|
||||||
|
<option key={option} value={option} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
<OptionSelect
|
<OptionSelect
|
||||||
id="acq-channel"
|
id="acq-channel"
|
||||||
label={ACQUISITION_FIELD_LABELS.channel}
|
label={ACQUISITION_FIELD_LABELS.channel}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
|||||||
<AcquisitionForm
|
<AcquisitionForm
|
||||||
editing={editing}
|
editing={editing}
|
||||||
options={data?.options ?? EMPTY_ACQUISITION_OPTIONS}
|
options={data?.options ?? EMPTY_ACQUISITION_OPTIONS}
|
||||||
|
providerSuggestions={data?.provider_suggestions ?? []}
|
||||||
onSaved={() => load(true)}
|
onSaved={() => load(true)}
|
||||||
onCancelEdit={() => setEditing(null)}
|
onCancelEdit={() => setEditing(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ import {
|
|||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Inhalte der Akquise-Dropdowns pflegen: "Von", "Anbieter", "Kanal", "Berater".
|
* Inhalte der Akquise-Dropdowns pflegen: "Von", "Kanal", "Berater".
|
||||||
*
|
*
|
||||||
* Vier gleich aufgebaute Listen statt vier Sonderfaelle - die Felder verhalten
|
* "Anbieter" steht bewusst nicht dabei - das Feld ist Freitext mit Vorschlaegen
|
||||||
* sich gleich, also sieht ihre Pflege auch gleich aus.
|
* aus den bisherigen Eintraegen.
|
||||||
|
*
|
||||||
|
* Gleich aufgebaute Listen statt Sonderfaelle - die Felder verhalten sich
|
||||||
|
* gleich, also sieht ihre Pflege auch gleich aus.
|
||||||
*
|
*
|
||||||
* Umbenennen zieht die vorhandenen Akquise-Eintraege mit, Loeschen nicht: eine
|
* Umbenennen zieht die vorhandenen Akquise-Eintraege mit, Loeschen nicht: eine
|
||||||
* Liste aufzuraeumen darf keine Daten aendern. Beide Antworten sagen, wie viele
|
* Liste aufzuraeumen darf keine Daten aendern. Beide Antworten sagen, wie viele
|
||||||
@@ -133,7 +136,6 @@ export const AcquisitionSettings: React.FC = () => {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [drafts, setDrafts] = useState<Record<AcquisitionOptionField, string>>({
|
const [drafts, setDrafts] = useState<Record<AcquisitionOptionField, string>>({
|
||||||
sender: '',
|
sender: '',
|
||||||
provider: '',
|
|
||||||
channel: '',
|
channel: '',
|
||||||
consultant: '',
|
consultant: '',
|
||||||
});
|
});
|
||||||
@@ -219,10 +221,11 @@ export const AcquisitionSettings: React.FC = () => {
|
|||||||
<section className="rounded-2xl border-l-4 border-l-brand-500 border border-ink-200 bg-surface p-5">
|
<section className="rounded-2xl border-l-4 border-l-brand-500 border border-ink-200 bg-surface p-5">
|
||||||
<h3 className="text-base text-ink-900">Auswahllisten der Akquise</h3>
|
<h3 className="text-base text-ink-900">Auswahllisten der Akquise</h3>
|
||||||
<p className="mt-1 text-xs text-ink-400">
|
<p className="mt-1 text-xs text-ink-400">
|
||||||
Diese vier Listen füllen die Dropdowns im Formular unter <em>Akquise</em>. Feste Auswahl
|
Diese drei Listen füllen die Dropdowns <em>Von</em>, <em>Kanal</em> und <em>Berater</em> im
|
||||||
statt Freitext, damit aus „Hays", „hays" und „Hays AG" nicht drei Anbieter werden.
|
Formular unter <em>Akquise</em>. Pflegen darf jeder angemeldete Benutzer - wer beim
|
||||||
Pflegen darf jeder angemeldete Benutzer - wer beim Eintragen auf einen neuen Anbieter
|
Eintragen einen fehlenden Wert braucht, soll weiterarbeiten können. Das Feld
|
||||||
stößt, soll weiterarbeiten können.
|
<em> Anbieter</em> steht bewusst nicht hier: dort wird frei getippt, mit Vorschlägen aus
|
||||||
|
den bisherigen Einträgen.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-xs text-ink-400">
|
<p className="mt-2 text-xs text-ink-400">
|
||||||
<strong>Umbenennen</strong> ändert die vorhandenen Akquise-Einträge mit.
|
<strong>Umbenennen</strong> ändert die vorhandenen Akquise-Einträge mit.
|
||||||
|
|||||||
+7
-5
@@ -253,16 +253,18 @@ export type OpportunityViewMode = 'grid' | 'list' | 'pipeline';
|
|||||||
/**
|
/**
|
||||||
* Felder der Akquise, die aus einer Auswahlliste kommen. Die Werte stehen in der
|
* Felder der Akquise, die aus einer Auswahlliste kommen. Die Werte stehen in der
|
||||||
* Datenbank (crm_acquisition_options) und werden unter Einstellungen -> Akquise
|
* Datenbank (crm_acquisition_options) und werden unter Einstellungen -> Akquise
|
||||||
* gepflegt - als Freitext waeren "Hays", "hays" und "Hays AG" drei Anbieter.
|
* gepflegt.
|
||||||
|
*
|
||||||
|
* "Anbieter" ist ausdruecklich nicht dabei: dort wird frei getippt, mit
|
||||||
|
* Vorschlaegen aus den bisherigen Eintraegen (`AcquisitionList.provider_suggestions`).
|
||||||
*/
|
*/
|
||||||
export const ACQUISITION_OPTION_FIELDS = ['sender', 'provider', 'channel', 'consultant'] as const;
|
export const ACQUISITION_OPTION_FIELDS = ['sender', 'channel', 'consultant'] as const;
|
||||||
|
|
||||||
export type AcquisitionOptionField = (typeof ACQUISITION_OPTION_FIELDS)[number];
|
export type AcquisitionOptionField = (typeof ACQUISITION_OPTION_FIELDS)[number];
|
||||||
|
|
||||||
/** Beschriftung der Felder - dieselbe in der Liste, im Formular und in den Einstellungen. */
|
/** Beschriftung der Felder - dieselbe in der Liste, im Formular und in den Einstellungen. */
|
||||||
export const ACQUISITION_FIELD_LABELS: Record<AcquisitionOptionField, string> = {
|
export const ACQUISITION_FIELD_LABELS: Record<AcquisitionOptionField, string> = {
|
||||||
sender: 'Von',
|
sender: 'Von',
|
||||||
provider: 'Anbieter',
|
|
||||||
channel: 'Kanal',
|
channel: 'Kanal',
|
||||||
consultant: 'Berater',
|
consultant: 'Berater',
|
||||||
};
|
};
|
||||||
@@ -270,7 +272,6 @@ export const ACQUISITION_FIELD_LABELS: Record<AcquisitionOptionField, string> =
|
|||||||
/** Was in der jeweiligen Liste steht - als Hilfe in den Einstellungen. */
|
/** Was in der jeweiligen Liste steht - als Hilfe in den Einstellungen. */
|
||||||
export const ACQUISITION_FIELD_HINTS: Record<AcquisitionOptionField, string> = {
|
export const ACQUISITION_FIELD_HINTS: Record<AcquisitionOptionField, string> = {
|
||||||
sender: 'Personen, von denen Projekte kommen',
|
sender: 'Personen, von denen Projekte kommen',
|
||||||
provider: 'Vermittler und Portale, z. B. Hays',
|
|
||||||
channel: 'Auf welchem Weg das Projekt kam',
|
channel: 'Auf welchem Weg das Projekt kam',
|
||||||
consultant: 'Eigene Berater, für die Projekte gesucht werden',
|
consultant: 'Eigene Berater, für die Projekte gesucht werden',
|
||||||
};
|
};
|
||||||
@@ -284,7 +285,6 @@ export type AcquisitionOptions = Record<AcquisitionOptionField, AcquisitionOptio
|
|||||||
|
|
||||||
export const EMPTY_ACQUISITION_OPTIONS: AcquisitionOptions = {
|
export const EMPTY_ACQUISITION_OPTIONS: AcquisitionOptions = {
|
||||||
sender: [],
|
sender: [],
|
||||||
provider: [],
|
|
||||||
channel: [],
|
channel: [],
|
||||||
consultant: [],
|
consultant: [],
|
||||||
};
|
};
|
||||||
@@ -337,6 +337,8 @@ export interface AcquisitionList {
|
|||||||
counts: { eingang: number; archiv: number };
|
counts: { eingang: number; archiv: number };
|
||||||
/** Inhalte der Dropdowns; kommen mit, damit die Eingabe sie sofort hat. */
|
/** Inhalte der Dropdowns; kommen mit, damit die Eingabe sie sofort hat. */
|
||||||
options: AcquisitionOptions;
|
options: AcquisitionOptions;
|
||||||
|
/** Schon benutzte Anbieter, der zuletzt verwendete zuerst - "Anbieter" ist Freitext. */
|
||||||
|
provider_suggestions: string[];
|
||||||
entries: Acquisition[];
|
entries: Acquisition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user