feat(akquise): mehrere Berater je Eintrag, je Berater eine Opportunity
Ein gefundenes Projekt passt oft zu mehr als einem Berater, und fuer jeden geht eine eigene Bewerbung heraus. Das Feld "Berater" liess bisher nur einen Namen zu. Gespeichert wird eine kommagetrennte Liste in crm_acquisitions.consultant (auf VARCHAR(500) erweitert, Migration in db.js) und keine eigene Zuordnungstabelle: die Namen sind reiner Text, die Auswahlliste in crm_acquisition_options ist eine Pflegehilfe und kein Fremdschluessel. Suche, Sortierung und Anzeige der Liste arbeiten damit weiter auf einem Feld. Die Funktionen zum Zerlegen stehen in acquisitionRules.js, das Gegenstueck fuer das Frontend in src/types.ts, geprueft von server/test/acquisition.test.mjs. Zerlegt wird an vier Stellen: - Uebernahme: je Berater eine Opportunity, alle in einer Transaktion. Ihre Titel tragen den Namen in Klammern, weil die Opportunity kein eigenes Feld fuer den Berater hat und in Pipeline und Liste sonst identische Zeilen stehen; bei einem einzigen Berater bleibt der Titel unveraendert. In den Notizen steht neben dem eigenen Berater auch, mit wem er sich das Projekt teilt. Der Eintrag merkt sich die erste Opportunity - sie entscheidet nur darueber, ob er wieder im Eingang auftaucht. - Verlauf (crm_acquisition_history): ein Berater je Zeile. Ein Eintrag mit drei Beratern sind drei Bewerbungen, "Nico, Lisa" waere sonst ein eigener Schluessel neben "Nico" und "Lisa". - Dashboard: dieselbe Zaehlung. Die Summe der Balken liegt damit ueber der Gesamtzahl "Bewerbungen", die weiter die Eintraege zaehlt. - Auswahlliste pflegen: Umbenennen ersetzt den Namen innerhalb der Liste, Loeschen zaehlt auch, wer nur einer von mehreren ist. Ein Gleichheitsvergleich uebersah beides. In der Oberflaeche haengt das Auswahlfeld jeweils einen Namen an, die Gewaehlten stehen als Plaketten darunter und lassen sich einzeln entfernen; ein bereits gewaehlter Berater verschwindet aus der Liste. Bewusst kein <select multiple>: Strg-Klick ist mit der Maus unauffindbar und mit einer Beruehrung gar nicht bedienbar. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a866cd9a52
commit
62dce64739
+14
-1
@@ -86,7 +86,7 @@ erDiagram
|
||||
varchar provider "Anbieter"
|
||||
varchar channel "Mail | Telefon | Portal | ..."
|
||||
varchar title "Projekttitel"
|
||||
varchar consultant "Berater"
|
||||
varchar consultant "Berater, mehrere kommagetrennt"
|
||||
varchar link "macht den Titel klickbar"
|
||||
datetime converted_at "uebernommen -> raus aus der Liste"
|
||||
int opportunity_id FK
|
||||
@@ -133,6 +133,19 @@ Acht Eigenheiten, die man kennen muss:
|
||||
- **`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
|
||||
wenige Kilobyte.
|
||||
- **Mehrere Berater stehen in einem Feld.** `crm_acquisitions.consultant`
|
||||
nimmt eine kommagetrennte Liste auf (`VARCHAR(500)`), keine eigene
|
||||
Zuordnungstabelle: die Namen sind reiner Text, die Auswahlliste in
|
||||
`crm_acquisition_options` ist eine Pflegehilfe und kein Fremdschlüssel. Suche,
|
||||
Sortierung und Anzeige der Liste arbeiten damit weiter auf einem Feld.
|
||||
Zerlegt wird an drei Stellen - im Verlauf, in der Auswertung und bei der
|
||||
Übernahme; die Funktionen dafür stehen in `server/src/acquisitionRules.js`,
|
||||
das Gegenstück fürs Frontend in `src/types.ts`.
|
||||
- **Der Verlauf zählt je Berater.** `crm_acquisition_history` hält einen Berater
|
||||
pro Zeile (Primärschlüssel `found_on, consultant`), auch wenn der Eintrag drei
|
||||
hatte: ein Fund für drei Berater sind drei versendete Profile. Die Summe im
|
||||
Balkendiagramm liegt deshalb über der Gesamtzahl *Bewerbungen*, die weiter die
|
||||
Einträge zählt.
|
||||
- **Das Dashboard rechnet aus zwei Quellen.** Jeder Eintrag in
|
||||
`crm_acquisitions` ist eine versendete Bewerbung. Weil die Akquise nach 90
|
||||
Tagen löscht, sichert der Aufräumlauf vorher Tag, Berater und Anzahl in
|
||||
|
||||
@@ -112,7 +112,10 @@ zeigt das Diagramm das **Ende** des Zeitraums und lässt sich nach links scrolle
|
||||
die aktuellen Tage stehen rechts.
|
||||
|
||||
Rechts daneben steht **Bewerbungen je Berater** für denselben Zeitraum. Einträge
|
||||
ohne hinterlegten Berater erscheinen als *Ohne Berater*.
|
||||
ohne hinterlegten Berater erscheinen als *Ohne Berater*. Ein Eintrag mit mehreren
|
||||
Beratern zählt bei **jedem** von ihnen: für jeden ist ein Profil herausgegangen.
|
||||
Die Summe der Balken liegt damit über der Gesamtzahl *Bewerbungen* oben, die die
|
||||
Einträge zählt - beides ist so gewollt.
|
||||
|
||||
### Anstehende Interviews und „Beim Kunden"
|
||||
|
||||
@@ -147,13 +150,26 @@ weg.
|
||||
| **Anbieter** | Freitext: Vermittler oder Portal, z. B. Hays. Vorschläge aus den bisherigen Einträgen |
|
||||
| **Kanal** | Auswahl: auf welchem Weg es kam |
|
||||
| **Projekttitel** | das einzige Pflichtfeld |
|
||||
| **Berater** | Auswahl: für wen das Projekt passt |
|
||||
| **Berater** | Auswahl: für wen das Projekt passt - **mehrere möglich** |
|
||||
| **Link** | Adresse der Ausschreibung. Ist einer hinterlegt, wird der Titel in der Liste anklickbar und öffnet ihn in einem neuen Tab |
|
||||
|
||||
*Von*, *Kanal* und *Berater* sind **Auswahllisten**. Ihre Inhalte stehen unter
|
||||
*Einstellungen → Akquise* und lassen sich dort von jedem Benutzer pflegen. Ist
|
||||
eine Liste noch leer, steht unter dem Feld ein Verweis dorthin.
|
||||
|
||||
**Berater** nimmt **mehrere Namen** auf - ein Projekt passt oft zu mehr als
|
||||
einem. Jede Auswahl hängt einen Namen an und erscheint darunter als Plakette;
|
||||
das Kreuz daran nimmt ihn wieder weg. Ein bereits gewählter Berater steht nicht
|
||||
mehr im Auswahlfeld, es zeigt also immer nur, wer noch zu haben ist.
|
||||
|
||||
> **Was mehrere Berater bedeuten:** Beim Übernehmen entsteht **je Berater eine
|
||||
> Opportunity** - jeder durchläuft seinen eigenen Vorgang, einer kann gewonnen
|
||||
> werden und ein anderer verloren. Ihre Titel bekommen den Namen in Klammern
|
||||
> angehängt, damit sie sich in Pipeline und Liste auseinanderhalten lassen; bei
|
||||
> einem einzigen Berater bleibt der Titel unverändert. Im Dashboard zählt der
|
||||
> Eintrag bei **jedem** der Berater - drei Berater sind drei versendete Profile,
|
||||
> also drei Bewerbungen.
|
||||
|
||||
**Von** ist mit dem **angemeldeten Benutzer** vorbelegt - in der Regel trägt
|
||||
jeder seine eigenen Funde ein. Trägt Markus etwas für Artur ein, wählt er Artur
|
||||
aus der Liste. Der eigene Name steht dabei immer zur Auswahl, auch wenn ihn
|
||||
@@ -183,7 +199,7 @@ wird geleert - für das nächste Projekt aus derselben Quelle.
|
||||
| **Von** | wer den Fund hereingebracht hat |
|
||||
| **Anbieter**, **Kanal** | woher es kam |
|
||||
| **Projekttitel** | mit Link anklickbar (neuer Tab); darunter klein Endkunde, Tagessatz bzw. Festpreis und Personentage |
|
||||
| **Berater** | für wen |
|
||||
| **Berater** | für wen; bei mehreren stehen sie als einzelne Plaketten |
|
||||
| **Opportunity** | ein Klick macht daraus eine Opportunity in der Phase *Erstgespräch Berater* |
|
||||
| Pfeil / Stift / Papierkorb | zurück in die Akquise (nur im Archiv), bearbeiten (füllt das Formular oben) und löschen (legt den Eintrag in den **Papierkorb**) |
|
||||
|
||||
@@ -268,6 +284,11 @@ im Archiv.
|
||||
Der Knopf **Opportunity** legt sie sofort an: Phase *Erstgespräch Berater*, 10 % Wahrscheinlichkeit,
|
||||
zuständig ist der angemeldete Benutzer. Mit übernommen werden Titel, Endkunde,
|
||||
Beschreibung, Tagessatz, Laufzeit, Projektpreis, Zeitraum und nächster Schritt.
|
||||
Sind **mehrere Berater** hinterlegt, entsteht **je Berater eine Opportunity**
|
||||
mit denselben Projektdaten; ihr Titel trägt den Namen in Klammern. In den Notizen
|
||||
steht neben dem eigenen Berater auch, mit wem er sich das Projekt teilt
|
||||
(*Auch beworben*).
|
||||
|
||||
Anbieter, Von, Kanal, Berater und Link haben in der Opportunity kein Feld - sie
|
||||
landen als Herkunftsvermerk in den internen Notizen, damit nachvollziehbar
|
||||
bleibt, woher das Projekt kam.
|
||||
|
||||
@@ -53,15 +53,21 @@ ansehen - siehe *Oberfläche prüfen ohne Datenbank* weiter unten.
|
||||
```bash
|
||||
npm run lint # = tsc --noEmit, das ist die Wahrheit
|
||||
npm run build # muss ohne Fehler durchlaufen
|
||||
cd server && npm test # Prüfungen der Postfachanbindung
|
||||
cd server && npm test # Postfachanbindung und Berater-Liste
|
||||
```
|
||||
|
||||
Automatische Tests gibt es nur für **einen** Bereich: `server/test/mail.test.mjs`
|
||||
prüft die Kontaktextraktion aus Mails und den Graph-Client. Beides läuft
|
||||
unbeaufsichtigt im Hintergrund und lässt sich von Hand kaum nachstellen - ein
|
||||
falsch erkannter Name landet still in der Kontaktliste, und ein 429 vom echten
|
||||
Dienst kommt genau dann, wenn niemand hinsieht. Kein Test-Framework, reines Node,
|
||||
keine Datenbank.
|
||||
Automatische Tests gibt es für zwei Bereiche, beide ohne Test-Framework, reines
|
||||
Node und ohne Datenbank:
|
||||
|
||||
* `server/test/mail.test.mjs` prüft die Kontaktextraktion aus Mails und den
|
||||
Graph-Client. Beides läuft unbeaufsichtigt im Hintergrund und lässt sich von
|
||||
Hand kaum nachstellen - ein falsch erkannter Name landet still in der
|
||||
Kontaktliste, und ein 429 vom echten Dienst kommt genau dann, wenn niemand
|
||||
hinsieht.
|
||||
* `server/test/acquisition.test.mjs` prüft das Zerlegen des Feldes *Berater*,
|
||||
das mehrere Namen kommagetrennt hält. Es steckt im Speichern, im Verlauf, in
|
||||
der Auswertung und in der Übernahme; ein Fehler darin fällt nicht auf, weil
|
||||
die Liste weiter Namen zeigt und nur die Zahlen falsch wären.
|
||||
|
||||
Für alles andere gilt: `tsc`, ein Build und ein Blick in die Oberfläche - notfalls
|
||||
mit dem Stub aus [Oberfläche prüfen ohne Datenbank](#oberflaeche-pruefen-ohne-datenbank).
|
||||
|
||||
@@ -94,7 +94,7 @@ Alle unter `router.use(requireAuth)`.
|
||||
| `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete |
|
||||
| `acquisitions.js` | `GET /?view=eingang\|archiv\|papierkorb\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `DELETE /:id/permanent`, `POST /trash/empty`, `POST /:id/opportunity`, `POST /:id/reopen`, `POST /:id/restore`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern, löschen und zurückholen nur Ersteller oder Admin; Auswahllisten und Leeren jeder Angemeldete. Zwei verschiedene Rückwege, nicht zu verwechseln: `/reopen` holt aus dem **Archiv** in den Eingang (setzt `found_at` auf heute und leert `converted_at`/`opportunity_id` - sonst hält die Übernahme den Eintrag im Archiv fest), `/restore` holt aus dem **Papierkorb** zurück (leert nur `deleted_at`/`deleted_by`, das Funddatum bleibt). Beide antworten 409, wenn der Eintrag im falschen Zustand ist |
|
||||
| `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv und dessen Papierkorb, Papierkorb der Opportunities und der Mails, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt |
|
||||
| `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IS_OPEN`, `IN_ARCHIVE`, `NOT_DUE`, `OPPORTUNITY_GONE`, `NOT_DELETED`, `IN_TRASH`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen |
|
||||
| `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IS_OPEN`, `IN_ARCHIVE`, `NOT_DUE`, `OPPORTUNITY_GONE`, `NOT_DELETED`, `IN_TRASH`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen. Dazu `splitConsultants` / `joinConsultants` / `normalizeConsultants` für das Feld *Berater*, das mehrere Namen kommagetrennt hält (Gegenstück im Frontend: `src/types.ts`) |
|
||||
| `dashboard.js` | `GET /?from=&to=&bucket=tag\|monat\|quartal\|jahr` | drei Reihen über der Zeit plus Bewerbungen je Berater. Liest Akquise **und** `crm_acquisition_history`; Termine und Phasenlisten stehen bewusst nicht drin - die lädt die App ohnehin |
|
||||
| `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/messages/:id/restore`, `/candidates/:id/adopt`, `/candidates/:id/reject`, `/trash/empty`; `DELETE /links/:id`, `/messages/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen, zuordnen und löschen jeder; einrichten Admin |
|
||||
|
||||
@@ -276,7 +276,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
|
||||
| `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. Der Pfeil zurück in den Eingang erscheint nur, wenn `onReopen` gesetzt ist - im Eingang gibt es nichts zurückzuholen |
|
||||
| `components/acquisition/AcquisitionTrash.tsx` | Papierkorb der Akquise: zurückholen, endgültig löschen, leeren. Die Meldung nach dem Zurückholen nennt das Ziel (Eingang oder Archiv) - das Funddatum bleibt, also landet ein alter Fund im Archiv |
|
||||
| `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen; `alwaysOffer` hält den eigenen Namen für *Von* immer wählbar. *Anbieter* nutzt es nicht: Freitext mit `<datalist>` |
|
||||
| `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen; `alwaysOffer` hält den eigenen Namen für *Von* immer wählbar. *Anbieter* nutzt es nicht: Freitext mit `<datalist>`. Dazu `ConsultantSelect` (mehrere Berater: Auswahlfeld hängt an, Plaketten nehmen weg) und `ConsultantList` für die Anzeige in der Liste |
|
||||
| `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen |
|
||||
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart |
|
||||
| `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs |
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"dev": "node --watch src/server.js",
|
||||
"migrate": "node src/migrate.js",
|
||||
"scanner:import": "node src/scanner/importLegacy.js",
|
||||
"test": "node test/mail.test.mjs"
|
||||
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -59,3 +59,55 @@ export const IN_ARCHIVE = '(a.converted_at IS NOT NULL OR a.found_at < DATE_SUB(
|
||||
|
||||
/** Untere Grenze: was schon faellig ist, wird nicht mehr gezeigt. */
|
||||
export const NOT_DUE = 'a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)';
|
||||
|
||||
/*
|
||||
* Mehrere Berater in einem Feld.
|
||||
*
|
||||
* Ein Fund passt oft zu mehr als einem Berater - fuer jeden davon geht eine
|
||||
* Bewerbung heraus. Die Namen stehen kommagetrennt in `crm_acquisitions.consultant`
|
||||
* und nicht in einer eigenen Tabelle: sie sind reiner Text (die Auswahlliste in
|
||||
* crm_acquisition_options ist eine Pflegehilfe, kein Fremdschluessel), und Suche,
|
||||
* Sortierung und Anzeige der Liste arbeiten weiter auf einem Feld.
|
||||
*
|
||||
* Der Verlauf (crm_acquisition_history) und die Auswertung zerlegen die Liste
|
||||
* dagegen wieder: dort steht ein Berater je Zeile, sonst waere "Nico, Lisa" ein
|
||||
* eigener Balken neben "Nico" und "Lisa".
|
||||
*/
|
||||
|
||||
/** Trennzeichen der gespeicherten Liste - mit Leerzeichen, das ist auch die Anzeige. */
|
||||
export const CONSULTANT_SEPARATOR = ', ';
|
||||
|
||||
/** Grenze der Spalte `consultant`; die Liste wird als Ganzes geprueft. */
|
||||
export const CONSULTANT_MAX_LENGTH = 500;
|
||||
|
||||
/**
|
||||
* Zerlegt das gespeicherte Feld in einzelne Namen. Leere Stuecke fallen weg
|
||||
* (ein Komma zu viel ist kein Berater), Dubletten ebenso - beim Vergleich ohne
|
||||
* Ruecksicht auf Gross- und Kleinschreibung, damit "nico" und "Nico" nicht
|
||||
* zwei Balken werden. Erhalten bleibt die erste Schreibweise.
|
||||
*/
|
||||
export function splitConsultants(value) {
|
||||
if (value === null || value === undefined) return [];
|
||||
const seen = new Set();
|
||||
const names = [];
|
||||
for (const part of String(value).split(',')) {
|
||||
const name = part.trim();
|
||||
if (name === '') continue;
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Setzt die Namen wieder zur gespeicherten Form zusammen; leer wird zu null. */
|
||||
export function joinConsultants(names) {
|
||||
const list = names.filter((name) => String(name).trim() !== '');
|
||||
return list.length === 0 ? null : list.join(CONSULTANT_SEPARATOR);
|
||||
}
|
||||
|
||||
/** Vereinheitlicht, was aus dem Formular kommt: getrimmt, ohne Dubletten, ohne Leere. */
|
||||
export function normalizeConsultants(value) {
|
||||
return joinConsultants(splitConsultants(value));
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export async function initDatabase() {
|
||||
await migrateLeadKind();
|
||||
await migrateOpportunityStages();
|
||||
await migrateMailPool();
|
||||
await widenAcquisitionConsultant();
|
||||
await seedAcquisitionOptions();
|
||||
await dropProviderOptions();
|
||||
await ensureAdminUser();
|
||||
@@ -243,6 +244,28 @@ async function dropProviderOptions() {
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Das Feld "Berater" nimmt mehrere Namen auf, kommagetrennt (siehe
|
||||
* acquisitionRules.js). VARCHAR(190) reichte fuer einen Namen; drei bis vier
|
||||
* volle Namen sprengen die Grenze, und MariaDB wuerde sie abschneiden.
|
||||
*
|
||||
* `CREATE TABLE IF NOT EXISTS` in schema.sql aendert eine bestehende Tabelle
|
||||
* nicht - deshalb hier der ALTER. Er laeuft nur, solange die Spalte noch zu
|
||||
* kurz ist, und ist damit bei jedem weiteren Start ein Blick in
|
||||
* INFORMATION_SCHEMA und sonst nichts.
|
||||
*/
|
||||
async function widenAcquisitionConsultant() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT CHARACTER_MAXIMUM_LENGTH AS length FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'crm_acquisitions'
|
||||
AND COLUMN_NAME = 'consultant' LIMIT 1`
|
||||
);
|
||||
if (!rows[0] || Number(rows[0].length) >= 500) return;
|
||||
|
||||
await pool.query('ALTER TABLE crm_acquisitions MODIFY consultant VARCHAR(500) NULL');
|
||||
console.log('[Akquise] Feld "Berater" auf 500 Zeichen erweitert - mehrere Berater je Eintrag.');
|
||||
}
|
||||
|
||||
async function ensureAdminUser() {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS count FROM crm_users');
|
||||
if (rows[0].count > 0) return;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { pool } from '../db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import {
|
||||
ARCHIVE_AFTER_DAYS,
|
||||
CONSULTANT_MAX_LENGTH,
|
||||
CONSULTANT_SEPARATOR,
|
||||
DELETE_AFTER_DAYS,
|
||||
IN_ARCHIVE,
|
||||
IN_TRASH,
|
||||
@@ -11,6 +13,8 @@ import {
|
||||
NOT_DUE,
|
||||
OPPORTUNITY_GONE,
|
||||
TRASH_DELETE_AFTER_DAYS,
|
||||
normalizeConsultants,
|
||||
splitConsultants,
|
||||
} from '../acquisitionRules.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -70,7 +74,8 @@ const MAX_LENGTH = {
|
||||
sender: 190,
|
||||
provider: 190,
|
||||
title: 190,
|
||||
consultant: 190,
|
||||
// Liste mehrerer Berater, deshalb weiter als die uebrigen Namensfelder.
|
||||
consultant: CONSULTANT_MAX_LENGTH,
|
||||
link: 1024,
|
||||
company: 190,
|
||||
next_step: 500,
|
||||
@@ -206,20 +211,62 @@ async function purgeOldEntries() {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
await conn.query(
|
||||
`INSERT INTO crm_acquisition_history (found_on, consultant, applications)
|
||||
SELECT a.found_at, COALESCE(a.consultant, ''), COUNT(*)
|
||||
/*
|
||||
* Gezaehlt wird je Berater: ein Eintrag mit drei Beratern sind drei
|
||||
* Bewerbungen. Deshalb wird die Liste vor dem Sichern zerlegt, statt
|
||||
* "Nico, Lisa" als eigenen Schluessel abzulegen - der waere in der
|
||||
* Auswertung ein eigener Balken neben "Nico" und "Lisa".
|
||||
*
|
||||
* Das Zerlegen passiert in JavaScript und nicht in SQL: MariaDB kann eine
|
||||
* kommagetrennte Liste nur mit einer Hilfstabelle aufspalten, und die
|
||||
* Datenmenge ist klein - der Lauf betrifft hoechstens die Eintraege eines
|
||||
* Tages, die gerade 90 Tage alt geworden sind.
|
||||
*/
|
||||
const [dueRows] = await conn.query(
|
||||
`SELECT a.found_at, a.consultant, COUNT(*) AS count
|
||||
FROM crm_acquisitions a
|
||||
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
||||
WHERE ${IS_OPEN}
|
||||
AND ${NOT_DELETED}
|
||||
AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||
GROUP BY a.found_at, COALESCE(a.consultant, '')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
applications = crm_acquisition_history.applications + VALUES(applications)`,
|
||||
GROUP BY a.found_at, a.consultant`,
|
||||
[DELETE_AFTER_DAYS]
|
||||
);
|
||||
|
||||
/*
|
||||
* Zusammengefasst wird auf Tag und Berater. Der leere Name steht fuer "ohne
|
||||
* Berater", weil der Primaerschluessel des Verlaufs kein NULL erlaubt; auf
|
||||
* 190 Zeichen gekuerzt, so weit reicht dort die Spalte.
|
||||
*/
|
||||
const perConsultant = new Map();
|
||||
for (const row of dueRows) {
|
||||
const names = splitConsultants(row.consultant);
|
||||
for (const name of names.length > 0 ? names : ['']) {
|
||||
const consultant = name.slice(0, 190);
|
||||
const key = `${row.found_at}|${consultant}`;
|
||||
const seen = perConsultant.get(key);
|
||||
if (seen) {
|
||||
seen.count += Number(row.count);
|
||||
} else {
|
||||
perConsultant.set(key, { foundOn: row.found_at, consultant, count: Number(row.count) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (perConsultant.size > 0) {
|
||||
const values = [];
|
||||
for (const entry of perConsultant.values()) {
|
||||
values.push(entry.foundOn, entry.consultant, entry.count);
|
||||
}
|
||||
await conn.query(
|
||||
`INSERT INTO crm_acquisition_history (found_on, consultant, applications)
|
||||
VALUES ${Array.from(perConsultant, () => '(?, ?, ?)').join(', ')}
|
||||
ON DUPLICATE KEY UPDATE
|
||||
applications = crm_acquisition_history.applications + VALUES(applications)`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Geloescht wird ueber dieselbe Regel: was in Eingang oder Archiv steht,
|
||||
* altert nach 90 Tagen heraus - auch ein Rueckfaeller. Eintraege mit
|
||||
@@ -321,7 +368,13 @@ function readFields(body, { partial }) {
|
||||
|
||||
for (const field of TEXT_FIELDS) {
|
||||
if (partial && body?.[field] === undefined) continue;
|
||||
const value = emptyToNull(body?.[field]);
|
||||
/*
|
||||
* "Berater" ist eine Liste mehrerer Namen: erst vereinheitlichen (getrimmt,
|
||||
* ohne Dubletten), dann messen - sonst wuerde eine Auswahl abgewiesen, die
|
||||
* nach dem Aufraeumen in die Spalte passt.
|
||||
*/
|
||||
const value =
|
||||
field === 'consultant' ? normalizeConsultants(body?.[field]) : emptyToNull(body?.[field]);
|
||||
if (MAX_LENGTH[field] && value !== null && value.length > MAX_LENGTH[field]) {
|
||||
return { error: `Der Wert für "${field}" ist zu lang (max. ${MAX_LENGTH[field]} Zeichen).` };
|
||||
}
|
||||
@@ -370,6 +423,29 @@ function readFields(body, { partial }) {
|
||||
return { columns };
|
||||
}
|
||||
|
||||
/**
|
||||
* Eintraege, in deren Berater-Liste `name` als eigener Name steht.
|
||||
*
|
||||
* Beim Feld "Berater" reicht ein `WHERE consultant = ?` nicht: der Name kann
|
||||
* einer von mehreren in einem Feld sein, und ein "Nico" in "Nico, Lisa" wuerde
|
||||
* beim Umbenennen und beim Zaehlen durchfallen. Ein `LIKE '%Nico%'` waere zu
|
||||
* grob - es traefe auch "Nicole".
|
||||
*
|
||||
* Deshalb wird verglichen, nachdem die Liste zerlegt ist. Alle Zeilen zu holen
|
||||
* ist vertretbar: die Tabelle haelt nur 90 Tage plus die uebernommenen
|
||||
* Eintraege, und der Weg wird nur beim Pflegen der Auswahlliste gegangen.
|
||||
*/
|
||||
async function acquisitionsWithConsultant(db, name, { onlyOpen = false } = {}) {
|
||||
const [rows] = await db.query(
|
||||
`SELECT id, consultant FROM crm_acquisitions
|
||||
WHERE consultant IS NOT NULL${onlyOpen ? ' AND converted_at IS NULL' : ''}`
|
||||
);
|
||||
const needle = name.toLowerCase();
|
||||
return rows.filter((row) =>
|
||||
splitConsultants(row.consultant).some((entry) => entry.toLowerCase() === needle)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auswahllisten (Einstellungen -> Akquise)
|
||||
//
|
||||
@@ -444,13 +520,37 @@ router.patch('/options/:id', async (req, res, next) => {
|
||||
|
||||
await conn.beginTransaction();
|
||||
await conn.query('UPDATE crm_acquisition_options SET value = ? WHERE id = ?', [value, id]);
|
||||
|
||||
let touched;
|
||||
if (existing.field === 'consultant') {
|
||||
/*
|
||||
* "Berater" ist eine Liste: der alte Name wird darin ersetzt, der Rest
|
||||
* bleibt stehen. `normalizeConsultants` faengt dabei den Fall ab, dass
|
||||
* durch das Umbenennen ein Name doppelt in derselben Liste landet.
|
||||
*/
|
||||
const affected = await acquisitionsWithConsultant(conn, existing.value);
|
||||
for (const row of affected) {
|
||||
const renamed = normalizeConsultants(
|
||||
splitConsultants(row.consultant)
|
||||
.map((entry) => (entry.toLowerCase() === existing.value.toLowerCase() ? value : entry))
|
||||
.join(CONSULTANT_SEPARATOR)
|
||||
);
|
||||
await conn.query('UPDATE crm_acquisitions SET consultant = ? WHERE id = ?', [
|
||||
renamed,
|
||||
row.id,
|
||||
]);
|
||||
}
|
||||
touched = affected.length;
|
||||
} else {
|
||||
const [updated] = await conn.query(
|
||||
`UPDATE crm_acquisitions SET ${existing.field} = ? WHERE ${existing.field} = ?`,
|
||||
[value, existing.value]
|
||||
);
|
||||
touched = updated.affectedRows;
|
||||
}
|
||||
await conn.commit();
|
||||
|
||||
res.json({ id, field: existing.field, value, updated: updated.affectedRows });
|
||||
res.json({ id, field: existing.field, value, updated: touched });
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {});
|
||||
if (err?.code === 'ER_DUP_ENTRY') {
|
||||
@@ -481,14 +581,23 @@ router.delete('/options/:id', async (req, res, next) => {
|
||||
const existing = rows[0];
|
||||
if (!existing) return res.status(404).json({ error: 'Eintrag der Liste nicht gefunden.' });
|
||||
|
||||
let stillInUse;
|
||||
if (existing.field === 'consultant') {
|
||||
// Mitgezaehlt wird auch, wer nur einer von mehreren Beratern eines
|
||||
// Eintrags ist - ein Gleichheitsvergleich uebersaehe ihn.
|
||||
const affected = await acquisitionsWithConsultant(pool, existing.value, { onlyOpen: true });
|
||||
stillInUse = affected.length;
|
||||
} else {
|
||||
const [[used]] = await pool.query(
|
||||
`SELECT COUNT(*) AS count FROM crm_acquisitions
|
||||
WHERE converted_at IS NULL AND ${existing.field} = ?`,
|
||||
[existing.value]
|
||||
);
|
||||
stillInUse = Number(used.count);
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM crm_acquisition_options WHERE id = ?', [id]);
|
||||
res.json({ ok: true, still_in_use: Number(used.count) });
|
||||
res.json({ ok: true, still_in_use: stillInUse });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -862,6 +971,11 @@ router.post('/:id/restore', async (req, res, next) => {
|
||||
* Transaktion: entweder die Opportunity steht und der Eintrag ist als
|
||||
* uebernommen vermerkt, oder es bleibt alles wie es war. Sonst koennte eine
|
||||
* Opportunity entstehen, die der Eingang weiterhin anbietet.
|
||||
*
|
||||
* Stehen mehrere Berater im Eintrag, wird je Berater eine Opportunity angelegt:
|
||||
* jeder durchlaeuft seinen eigenen Verkaufsvorgang, einer kann gewonnen werden
|
||||
* und ein anderer verloren. Alle in einer Transaktion - ein halb uebernommener
|
||||
* Eintrag waere schlimmer als ein fehlgeschlagener.
|
||||
*/
|
||||
router.post('/:id/opportunity', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
@@ -885,36 +999,62 @@ router.post('/:id/opportunity', async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Ohne Berater bleibt es bei einer Opportunity - `null` steht hier fuer
|
||||
* "kein Berater hinterlegt" und laeuft durch dieselbe Schleife.
|
||||
*/
|
||||
const consultants = splitConsultants(entry.consultant);
|
||||
const targets = consultants.length > 0 ? consultants : [null];
|
||||
|
||||
/*
|
||||
* Was in der Liste steht, aber in der Opportunity kein Feld hat (Anbieter,
|
||||
* Kanal, Absender, Link, Berater), wandert in die Notizen. Sonst wuesste
|
||||
* hinterher niemand mehr, woher das Projekt kam.
|
||||
* hinterher niemand mehr, woher das Projekt kam. In den Notizen steht der
|
||||
* Berater dieser Opportunity, nicht die ganze Liste - und dazu, mit wem er
|
||||
* sich das Projekt teilt.
|
||||
*/
|
||||
const buildNotes = (consultant) => {
|
||||
const others = targets.filter((name) => name !== null && name !== consultant);
|
||||
const origin = [
|
||||
['Anbieter', entry.provider],
|
||||
['Von', entry.sender],
|
||||
['Kanal', entry.channel],
|
||||
['Berater', entry.consultant],
|
||||
['Berater', consultant],
|
||||
['Auch beworben', others.length > 0 ? others.join(', ') : null],
|
||||
['Link', entry.link],
|
||||
]
|
||||
.filter(([, value]) => value)
|
||||
.map(([label, value]) => `${label}: ${value}`);
|
||||
|
||||
const notes = [
|
||||
return (
|
||||
[
|
||||
entry.notes,
|
||||
origin.length > 0 ? `Aus der Akquise vom ${entry.found_at}\n${origin.join('\n')}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
.join('\n\n') || null
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* Bei mehreren Beratern kommt der Name in den Titel: in Pipeline und Liste
|
||||
* standen sonst drei Zeilen mit identischem Text, und die Opportunity hat
|
||||
* kein eigenes Feld fuer den Berater. Bei einem einzigen Berater bleibt der
|
||||
* Titel unveraendert - das ist der Normalfall und braucht keinen Zusatz.
|
||||
*/
|
||||
const titleFor = (consultant) =>
|
||||
targets.length > 1 && consultant ? `${entry.title} (${consultant})`.slice(0, 190) : entry.title;
|
||||
|
||||
await conn.beginTransaction();
|
||||
const opportunityIds = [];
|
||||
for (const consultant of targets) {
|
||||
const [created] = await conn.query(
|
||||
`INSERT INTO crm_opportunities
|
||||
(title, company, description, stage, probability, daily_rate, duration_days,
|
||||
fixed_price, start_date, end_date, next_step, tags, notes, owner_id, created_by)
|
||||
VALUES (?, ?, ?, 'Erstgespraech Berater', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
entry.title,
|
||||
titleFor(consultant),
|
||||
entry.company,
|
||||
entry.description,
|
||||
entry.daily_rate,
|
||||
@@ -924,19 +1064,32 @@ router.post('/:id/opportunity', async (req, res, next) => {
|
||||
entry.end_date,
|
||||
entry.next_step,
|
||||
entry.tags ?? '[]',
|
||||
notes || null,
|
||||
buildNotes(consultant),
|
||||
req.user.id,
|
||||
req.user.id,
|
||||
]
|
||||
);
|
||||
opportunityIds.push(created.insertId);
|
||||
}
|
||||
|
||||
/*
|
||||
* `opportunity_id` ist eine Spalte, also merkt sich der Eintrag die erste.
|
||||
* Sie entscheidet nur darueber, ob er wieder im Eingang auftaucht: wird sie
|
||||
* geloescht, laesst sich erneut uebernehmen. Die uebrigen bleiben davon
|
||||
* unberuehrt - dafuer steht ihre Herkunft in den Notizen.
|
||||
*/
|
||||
await conn.query(
|
||||
'UPDATE crm_acquisitions SET opportunity_id = ?, converted_at = UTC_TIMESTAMP() WHERE id = ?',
|
||||
[created.insertId, id]
|
||||
[opportunityIds[0], id]
|
||||
);
|
||||
await conn.commit();
|
||||
|
||||
res.status(201).json({ ok: true, opportunity_id: created.insertId });
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
opportunity_id: opportunityIds[0],
|
||||
opportunity_ids: opportunityIds,
|
||||
consultants,
|
||||
});
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {});
|
||||
next(err);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express from 'express';
|
||||
import { pool } from '../db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { splitConsultants } from '../acquisitionRules.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
@@ -99,10 +100,21 @@ router.get('/', async (req, res, next) => {
|
||||
[from, toExclusive]
|
||||
);
|
||||
|
||||
// Bewerbungen je Berater - lebende Eintraege und Verlauf zusammen.
|
||||
/*
|
||||
* Bewerbungen je Berater - lebende Eintraege und Verlauf zusammen.
|
||||
*
|
||||
* Ein Eintrag kann mehrere Berater tragen (kommagetrennt, siehe
|
||||
* acquisitionRules.js); jeder davon ist eine eigene Bewerbung und wird
|
||||
* einzeln gezaehlt. Die Summe dieser Balken liegt damit ueber der Gesamtzahl
|
||||
* "Bewerbungen" oben, die weiter die Eintraege zaehlt - beides ist
|
||||
* beabsichtigt: ein Fund, drei verschickte Profile.
|
||||
*
|
||||
* Zusammengefasst wird in JavaScript, weil SQL die Liste nicht ohne
|
||||
* Hilfstabelle aufspalten kann. Der Verlauf liegt schon je Berater vor.
|
||||
*/
|
||||
const [consultantRows] = await pool.query(
|
||||
`SELECT consultant, SUM(count) AS count FROM (
|
||||
SELECT COALESCE(NULLIF(TRIM(consultant), ''), '') AS consultant, COUNT(*) AS count
|
||||
SELECT COALESCE(consultant, '') AS consultant, COUNT(*) AS count
|
||||
FROM crm_acquisitions
|
||||
WHERE deleted_at IS NULL
|
||||
AND found_at BETWEEN ? AND ?
|
||||
@@ -113,11 +125,27 @@ router.get('/', async (req, res, next) => {
|
||||
WHERE found_on BETWEEN ? AND ?
|
||||
GROUP BY 1
|
||||
) AS beide
|
||||
GROUP BY consultant
|
||||
ORDER BY count DESC, consultant ASC`,
|
||||
GROUP BY consultant`,
|
||||
[from, to, from, to]
|
||||
);
|
||||
|
||||
// Leerer Text heisst "kein Berater hinterlegt" und bleibt ein eigener Balken.
|
||||
const perConsultant = new Map();
|
||||
for (const row of consultantRows) {
|
||||
const names = splitConsultants(row.consultant);
|
||||
for (const name of names.length > 0 ? names : ['']) {
|
||||
perConsultant.set(name, (perConsultant.get(name) ?? 0) + Number(row.count));
|
||||
}
|
||||
}
|
||||
|
||||
const byConsultant = [...perConsultant.entries()]
|
||||
.map(([consultant, applications]) => ({ consultant, applications }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.applications - left.applications ||
|
||||
left.consultant.localeCompare(right.consultant, 'de')
|
||||
);
|
||||
|
||||
const live = toMap(applicationRows);
|
||||
const history = toMap(historyRows);
|
||||
const applications = { ...live };
|
||||
@@ -139,11 +167,11 @@ router.get('/', async (req, res, next) => {
|
||||
converted: sum(converted),
|
||||
won: sum(won),
|
||||
},
|
||||
by_consultant: consultantRows.map((row) => ({
|
||||
by_consultant: byConsultant.map((row) => ({
|
||||
// Leerer Text heisst "kein Berater hinterlegt" - das Frontend
|
||||
// beschriftet es, damit hier kein Anzeigetext entsteht.
|
||||
consultant: row.consultant === '' ? null : row.consultant,
|
||||
applications: Number(row.count),
|
||||
applications: row.applications,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -408,7 +408,7 @@ CREATE TABLE IF NOT EXISTS crm_acquisitions (
|
||||
provider VARCHAR(190) NULL, -- "Anbieter": Vermittler, Portal
|
||||
channel VARCHAR(60) NULL, -- "Kanal": Mail, Telefon, Portal, ...
|
||||
title VARCHAR(190) NOT NULL, -- Projekttitel
|
||||
consultant VARCHAR(190) NULL, -- "Berater", fuer den es passt
|
||||
consultant VARCHAR(500) NULL, -- "Berater": ein Name oder mehrere, kommagetrennt
|
||||
link VARCHAR(1024) NULL, -- Ausschreibung; macht den Titel klickbar
|
||||
-- Ab hier: steht nicht in der Liste, wandert aber in die Opportunity.
|
||||
company VARCHAR(190) NULL, -- Endkunde
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Pruefungen der Berater-Liste: laeuft mit `npm test` im Verzeichnis server/.
|
||||
*
|
||||
* Ohne Test-Framework, wie mail.test.mjs - reines Node.
|
||||
*
|
||||
* Geprueft wird das Zerlegen und Zusammensetzen des Feldes "Berater". Es steckt
|
||||
* an vier Stellen (Speichern, Verlauf, Auswertung, Uebernahme in Opportunities),
|
||||
* und ein Fehler darin faellt nicht auf: die Liste zeigt weiter Namen, nur die
|
||||
* Zahlen im Dashboard oder die Anzahl der Opportunities waeren falsch.
|
||||
*
|
||||
* Das Gegenstueck im Frontend (src/types.ts) ist dieselbe Logik in TypeScript;
|
||||
* wer hier etwas aendert, muss dort mitziehen.
|
||||
*/
|
||||
const { splitConsultants, joinConsultants, normalizeConsultants, CONSULTANT_SEPARATOR } =
|
||||
await import('../src/acquisitionRules.js');
|
||||
|
||||
let failures = 0;
|
||||
|
||||
function check(name, condition, detail = '') {
|
||||
if (condition) {
|
||||
console.log(` ok ${name}${detail ? ` - ${detail}` : ''}`);
|
||||
} else {
|
||||
console.log(` FAIL ${name}${detail ? ` - ${detail}` : ''}`);
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
||||
|
||||
console.log('\nZerlegen');
|
||||
check('Einzelner Name', same(splitConsultants('Nico'), ['Nico']));
|
||||
check('Mehrere Namen', same(splitConsultants('Nico, Lisa'), ['Nico', 'Lisa']));
|
||||
check(
|
||||
'Ohne Leerzeichen hinter dem Komma',
|
||||
same(splitConsultants('Nico,Lisa,Gunther'), ['Nico', 'Lisa', 'Gunther'])
|
||||
);
|
||||
check(
|
||||
'Umlaute und zusammengesetzte Namen bleiben ganz',
|
||||
same(splitConsultants('Niklas Mülln, Luisa Sauerwald'), ['Niklas Mülln', 'Luisa Sauerwald'])
|
||||
);
|
||||
check('Leeres Feld', same(splitConsultants(''), []));
|
||||
check('null', same(splitConsultants(null), []));
|
||||
check('Nur Kommas', same(splitConsultants(' , , '), []));
|
||||
check('Leeres Stueck faellt weg', same(splitConsultants('Nico, , Lisa'), ['Nico', 'Lisa']));
|
||||
check(
|
||||
'Dublette faellt weg, erste Schreibweise bleibt',
|
||||
same(splitConsultants('Nico, nico, NICO'), ['Nico'])
|
||||
);
|
||||
|
||||
console.log('\nZusammensetzen');
|
||||
check('Mehrere Namen', joinConsultants(['Nico', 'Lisa']) === `Nico${CONSULTANT_SEPARATOR}Lisa`);
|
||||
check('Leere Liste wird null', joinConsultants([]) === null);
|
||||
check('Nur Leerzeichen wird null', joinConsultants([' ', '']) === null);
|
||||
|
||||
console.log('\nVereinheitlichen (so kommt es aus dem Formular)');
|
||||
check('Getrimmt und normiert', normalizeConsultants(' Nico ,Lisa ') === 'Nico, Lisa');
|
||||
check('Dubletten weg', normalizeConsultants('Nico, Nico') === 'Nico');
|
||||
check('Leer wird null', normalizeConsultants(' ') === null);
|
||||
check(
|
||||
'Zweimal vereinheitlichen aendert nichts',
|
||||
normalizeConsultants(normalizeConsultants('Nico,Lisa, nico')) === 'Nico, Lisa'
|
||||
);
|
||||
|
||||
/*
|
||||
* Das Zaehlen je Berater, wie es der Verlauf und das Dashboard machen: ein
|
||||
* Eintrag mit drei Beratern sind drei Bewerbungen.
|
||||
*/
|
||||
console.log('\nZaehlen je Berater');
|
||||
const rows = [
|
||||
{ consultant: 'Nico, Lisa', count: 1 },
|
||||
{ consultant: 'Nico', count: 2 },
|
||||
{ consultant: null, count: 4 },
|
||||
];
|
||||
const perConsultant = new Map();
|
||||
for (const row of rows) {
|
||||
const names = splitConsultants(row.consultant);
|
||||
for (const name of names.length > 0 ? names : ['']) {
|
||||
perConsultant.set(name, (perConsultant.get(name) ?? 0) + row.count);
|
||||
}
|
||||
}
|
||||
check('Berater in zwei Eintraegen', perConsultant.get('Nico') === 3);
|
||||
check('Berater in einem Eintrag', perConsultant.get('Lisa') === 1);
|
||||
check('Ohne Berater bleibt eine eigene Gruppe', perConsultant.get('') === 4);
|
||||
check(
|
||||
'Summe liegt ueber der Zahl der Eintraege',
|
||||
[...perConsultant.values()].reduce((total, value) => total + value, 0) === 8
|
||||
);
|
||||
|
||||
console.log(
|
||||
failures === 0 ? '\nAlle Pruefungen bestanden.' : `\n${failures} Pruefung(en) fehlgeschlagen.`
|
||||
);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { DictateTextarea } from '../ui/DictationField';
|
||||
import { OptionSelect, fieldClass } from './acquisitionUi';
|
||||
import { ConsultantSelect, OptionSelect, fieldClass } from './acquisitionUi';
|
||||
import { labelClass } from '../leads/leadUi';
|
||||
import { useToast } from '../Toast';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
@@ -330,7 +330,9 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<OptionSelect
|
||||
{/* Mehrfachauswahl: ein Projekt passt oft zu mehreren Beratern, und
|
||||
beim Uebernehmen entsteht dann je Berater eine Opportunity. */}
|
||||
<ConsultantSelect
|
||||
id="acq-consultant"
|
||||
label={ACQUISITION_FIELD_LABELS.consultant}
|
||||
value={form.consultant}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { formatEuro, type Acquisition } from '../../types';
|
||||
import { formatEuro, splitConsultants, type Acquisition } from '../../types';
|
||||
import { ConsultantList } from './acquisitionUi';
|
||||
|
||||
/*
|
||||
* Die Liste der gefundenen Projekte. Genau die Spalten, die beim Durchgehen
|
||||
@@ -155,6 +156,9 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
|
||||
const extraParts = extras(entry);
|
||||
const returned = returnedNote(entry);
|
||||
const isBusy = busyId === entry.id;
|
||||
// Entscheidet nur die Beschriftung: bei mehreren Beratern entsteht
|
||||
// beim Uebernehmen je Berater eine Opportunity.
|
||||
const consultantCount = splitConsultants(entry.consultant).length;
|
||||
|
||||
return (
|
||||
<tr
|
||||
@@ -212,13 +216,19 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3 text-sm text-ink-700">{entry.consultant || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-ink-700">
|
||||
<ConsultantList value={entry.consultant} />
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => onConvert(entry)}
|
||||
disabled={isBusy}
|
||||
title="Als neue Opportunity in der Phase „Erstgespräch Berater“ anlegen. Der Eintrag verschwindet danach aus dieser Liste."
|
||||
title={
|
||||
consultantCount > 1
|
||||
? `Je Berater eine Opportunity in der Phase „Erstgespräch Berater“ anlegen - ${consultantCount} insgesamt. Der Eintrag verschwindet danach aus dieser Liste.`
|
||||
: 'Als neue Opportunity in der Phase „Erstgespräch Berater“ anlegen. Der Eintrag verschwindet danach aus dieser Liste.'
|
||||
}
|
||||
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl bg-brand-500 px-3 py-1.5 text-xs font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-50"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { UserAvatar } from '../ui/UserAvatar';
|
||||
import { useToast } from '../Toast';
|
||||
import { acquisitionsApi } from '../../lib/api';
|
||||
import { formatEuro, type Acquisition } from '../../types';
|
||||
import { ConsultantList } from './acquisitionUi';
|
||||
|
||||
/*
|
||||
* Papierkorb der Akquise.
|
||||
@@ -227,7 +228,9 @@ export const AcquisitionTrash: React.FC<AcquisitionTrashProps> = ({ reload }) =>
|
||||
<td className="px-4 py-3 text-sm font-bold text-ink-800">
|
||||
{entry.provider || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-ink-700">{entry.consultant || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-ink-700">
|
||||
<ConsultantList value={entry.consultant} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm tabular-nums text-ink-700">
|
||||
{formatFound(entry.found_at)}
|
||||
</td>
|
||||
|
||||
@@ -131,8 +131,15 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
||||
const convert = async (entry: Acquisition) => {
|
||||
setBusyId(entry.id);
|
||||
try {
|
||||
await acquisitionsApi.toOpportunity(entry.id);
|
||||
addToast(`Opportunity „${entry.title}" angelegt - Phase „Erstgespräch Berater".`, 'success');
|
||||
const result = await acquisitionsApi.toOpportunity(entry.id);
|
||||
// Bei mehreren Beratern entsteht je Berater eine Opportunity - das muss in
|
||||
// der Meldung stehen, sonst wundert man sich ueber drei neue Eintraege.
|
||||
addToast(
|
||||
result.opportunity_ids.length > 1
|
||||
? `${result.opportunity_ids.length} Opportunities zu „${entry.title}" angelegt - je Berater eine, Phase „Erstgespräch Berater".`
|
||||
: `Opportunity „${entry.title}" angelegt - Phase „Erstgespräch Berater".`,
|
||||
'success'
|
||||
);
|
||||
if (editing?.id === entry.id) setEditing(null);
|
||||
await load(true);
|
||||
// Die Opportunity-Liste der App kennt den neuen Eintrag noch nicht.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { labelClass } from '../leads/leadUi';
|
||||
import type { AcquisitionOption } from '../../types';
|
||||
import { joinConsultants, splitConsultants, type AcquisitionOption } from '../../types';
|
||||
|
||||
/*
|
||||
* Gemeinsame Bausteine der Akquise.
|
||||
@@ -85,3 +85,138 @@ export const OptionSelect: React.FC<OptionSelectProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ConsultantSelectProps {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Kommagetrennte Liste, genau so wie sie gespeichert wird. */
|
||||
value: string;
|
||||
options: AcquisitionOption[];
|
||||
onChange: (value: string) => void;
|
||||
/** Fuehrt zu Einstellungen -> Akquise, wo die Liste gepflegt wird. */
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auswahl mehrerer Berater. Ein Fund passt oft zu mehr als einem Berater, und
|
||||
* fuer jeden geht eine eigene Bewerbung heraus.
|
||||
*
|
||||
* Bewusst kein `<select multiple>` und kein eigenes Aufklappmenue: Mehrfachwahl
|
||||
* per Strg-Klick ist mit der Maus unauffindbar und mit einer Berührung gar nicht
|
||||
* bedienbar, ein selbstgebautes Menue braucht Klick-nach-draussen, Fokusfalle
|
||||
* und Tastaturpfeile. Stattdessen zwei bekannte Bausteine: ein normales
|
||||
* Auswahlfeld, das jeweils einen Namen hinzufuegt, und die Gewaehlten als
|
||||
* Plaketten mit einem Kreuz zum Entfernen. Ein bereits gewaehlter Name steht
|
||||
* nicht mehr in der Liste - damit ist das Feld immer "der naechste Berater".
|
||||
*/
|
||||
export const ConsultantSelect: React.FC<ConsultantSelectProps> = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
onOpenSettings,
|
||||
}) => {
|
||||
const selected = splitConsultants(value);
|
||||
const chosen = new Set(selected.map((name) => name.toLowerCase()));
|
||||
|
||||
// Was noch zu haben ist. Ein Wert aus einem alten Eintrag, den die Liste nicht
|
||||
// mehr kennt, bleibt als Plakette stehen - nur nachwaehlen kann man ihn nicht.
|
||||
const open = options.filter((option) => !chosen.has(option.value.toLowerCase()));
|
||||
|
||||
const add = (name: string) => {
|
||||
if (name === '') return;
|
||||
onChange(joinConsultants([...selected, name]));
|
||||
};
|
||||
|
||||
const remove = (name: string) => {
|
||||
onChange(joinConsultants(selected.filter((entry) => entry !== name)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className={labelClass} htmlFor={id}>
|
||||
{label}
|
||||
{selected.length > 1 && (
|
||||
<span className="ml-1.5 font-bold normal-case tracking-normal text-ink-400">
|
||||
{selected.length} ausgewählt
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Das Auswahlfeld zeigt nie einen Wert an: es fuegt hinzu und springt
|
||||
zurueck auf die Aufforderung. Der Stand steht in den Plaketten. */}
|
||||
<select
|
||||
id={id}
|
||||
value=""
|
||||
onChange={(event) => add(event.target.value)}
|
||||
className={fieldClass}
|
||||
>
|
||||
<option value="">{selected.length > 0 ? 'weiteren hinzufügen …' : '—'}</option>
|
||||
{open.map((option) => (
|
||||
<option key={option.id} value={option.value}>
|
||||
{option.value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<ul className="mt-2 flex flex-wrap gap-1.5">
|
||||
{selected.map((name) => (
|
||||
<li key={name}>
|
||||
<span className="inline-flex items-center gap-1 rounded-lg bg-brand-500/10 py-1 pl-2.5 pr-1 text-xs font-bold text-brand-700">
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(name)}
|
||||
aria-label={`${name} entfernen`}
|
||||
title={`${name} entfernen`}
|
||||
className="rounded px-1 text-brand-700/60 transition hover:bg-brand-500/20 hover:text-brand-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{options.length === 0 && (
|
||||
<p className="mt-1.5 text-[11px] text-ink-400">
|
||||
Liste ist leer -{' '}
|
||||
<button type="button" onClick={onOpenSettings} className="font-bold underline">
|
||||
unter Einstellungen pflegen
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{options.length > 0 && open.length === 0 && (
|
||||
<p className="mt-1.5 text-[11px] text-ink-400">Alle Berater aus der Liste sind gewählt.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Berater eines Eintrags in der Liste. Ein einzelner Name bleibt schlichter
|
||||
* Text - so sah die Spalte immer aus. Erst bei mehreren werden Plaketten
|
||||
* daraus: in einer schmalen Spalte laesst sich "Nico, Ariane Wagner, Lisa"
|
||||
* sonst nicht auf einen Blick trennen.
|
||||
*/
|
||||
export const ConsultantList: React.FC<{ value: string | null }> = ({ value }) => {
|
||||
const names = splitConsultants(value);
|
||||
if (names.length === 0) return <>—</>;
|
||||
if (names.length === 1) return <>{names[0]}</>;
|
||||
|
||||
return (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{names.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="rounded-md bg-ink-100 px-1.5 py-0.5 text-xs font-bold text-ink-600"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
+12
-2
@@ -319,9 +319,19 @@ export const acquisitionsApi = {
|
||||
emptyTrash: () =>
|
||||
request<{ ok: true; purged: number }>('/acquisitions/trash/empty', { method: 'POST' }),
|
||||
|
||||
/** Legt die Opportunity an und vermerkt den Eintrag als uebernommen. */
|
||||
/**
|
||||
* Legt die Opportunity an und vermerkt den Eintrag als uebernommen. Stehen
|
||||
* mehrere Berater im Eintrag, entsteht je Berater eine - `opportunity_ids`
|
||||
* zaehlt alle auf, `opportunity_id` ist die erste (die der Eintrag sich
|
||||
* merkt).
|
||||
*/
|
||||
toOpportunity: (id: number) =>
|
||||
request<{ ok: true; opportunity_id: number }>(`/acquisitions/${id}/opportunity`, {
|
||||
request<{
|
||||
ok: true;
|
||||
opportunity_id: number;
|
||||
opportunity_ids: number[];
|
||||
consultants: string[];
|
||||
}>(`/acquisitions/${id}/opportunity`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
|
||||
+39
-1
@@ -333,6 +333,41 @@ export interface AcquisitionOption {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* "Berater" nimmt mehrere Namen auf - ein Fund passt oft zu mehr als einem, und
|
||||
* fuer jeden geht eine Bewerbung heraus. Gespeichert wird eine kommagetrennte
|
||||
* Liste in einem Feld; das Gegenstueck im Backend steht in
|
||||
* server/src/acquisitionRules.js. Wer hier etwas aendert, muss dort mitziehen.
|
||||
*/
|
||||
|
||||
/** Trennzeichen der gespeicherten Liste - mit Leerzeichen, das ist auch die Anzeige. */
|
||||
export const CONSULTANT_SEPARATOR = ', ';
|
||||
|
||||
/**
|
||||
* Zerlegt das Feld in einzelne Namen. Leere Stuecke und Dubletten fallen weg -
|
||||
* verglichen wird ohne Ruecksicht auf Gross- und Kleinschreibung, erhalten
|
||||
* bleibt die erste Schreibweise.
|
||||
*/
|
||||
export function splitConsultants(value: string | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
const seen = new Set<string>();
|
||||
const names: string[] = [];
|
||||
for (const part of value.split(',')) {
|
||||
const name = part.trim();
|
||||
if (name === '') continue;
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Setzt die Namen wieder zur gespeicherten Form zusammen. */
|
||||
export function joinConsultants(names: string[]): string {
|
||||
return names.filter((name) => name.trim() !== '').join(CONSULTANT_SEPARATOR);
|
||||
}
|
||||
|
||||
export type AcquisitionOptions = Record<AcquisitionOptionField, AcquisitionOption[]>;
|
||||
|
||||
export const EMPTY_ACQUISITION_OPTIONS: AcquisitionOptions = {
|
||||
@@ -357,7 +392,10 @@ export interface Acquisition {
|
||||
provider: string | null;
|
||||
channel: string | null;
|
||||
title: string;
|
||||
/** "Berater", fuer den es passt. */
|
||||
/**
|
||||
* "Berater", fuer den es passt - bei mehreren eine kommagetrennte Liste
|
||||
* (`splitConsultants`).
|
||||
*/
|
||||
consultant: string | null;
|
||||
/** Ausschreibung; ist er gesetzt, wird der Titel in der Liste klickbar. */
|
||||
link: string | null;
|
||||
|
||||
Reference in New Issue
Block a user