feat(akquise): Auswahllisten statt Freitext, Felder abgesetzt
"Von", "Anbieter", "Kanal" und "Berater" sind Dropdowns. Ihre Inhalte stehen in crm_acquisition_options und werden im neuen Reiter Einstellungen -> Akquise gepflegt. Freitext hat aus "Hays", "hays" und "Hays AG" drei Anbieter gemacht; der Unique-Schluessel greift ueber die Kollation auch bei abweichender Gross-/Kleinschreibung. Eine Tabelle mit Feldkennzeichen statt vier Tabellen: die Felder verhalten sich gleich, also ist jede weitere Liste ein Wert im ENUM statt einer neuen Route und einer neuen Oberflaeche. Zwei Entscheidungen dabei: - Pflegen darf jeder Angemeldete, nicht nur ein Administrator. Eine Liste, die nur ein Admin ergaenzen kann, blockiert die Eingabe genau dann, wenn ein neuer Anbieter auftaucht. - Die Eintraege halten den Text, keinen Fremdschluessel. Umbenennen zieht sie darum ausdruecklich mit (in einer Transaktion), Loeschen nicht: eine Liste aufzuraeumen darf keine Daten aendern. Ein Wert, der nicht mehr in der Liste steht, wird im Dropdown weiter angeboten und als solcher gekennzeichnet - sonst wuerde das Bearbeiten eines alten Eintrags ihn stillschweigend ueberschreiben. Das Backend prueft beim Speichern eines Eintrags nur noch die Laenge und nicht mehr die Zugehoerigkeit zur Liste - aus demselben Grund. Dazu: die Felder der Akquise-Eingabe sind abgesetzt (getoente Flaeche, deutlicherer Rahmen, beim Hineinklicken weiss). Auf der weissen Karte gingen sie unter, und im Formular stehen bis zu fuenfzehn davon nebeneinander. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b8a78f609c
commit
05cc261900
+15
-1
@@ -25,6 +25,7 @@ erDiagram
|
||||
crm_opportunities ||--o{ crm_opportunity_contacts : hat
|
||||
crm_users ||--o{ crm_acquisitions : created_by
|
||||
crm_acquisitions |o--o| crm_opportunities : "wird zu"
|
||||
crm_acquisition_options ||--o{ crm_acquisitions : "fuellt Von, Anbieter, Kanal, Berater"
|
||||
|
||||
crm_users {
|
||||
int id PK
|
||||
@@ -88,6 +89,11 @@ erDiagram
|
||||
datetime converted_at "uebernommen -> raus aus der Liste"
|
||||
int opportunity_id FK
|
||||
}
|
||||
crm_acquisition_options {
|
||||
int id PK
|
||||
enum field "sender | provider | channel | consultant"
|
||||
varchar value "Inhalt des Dropdowns"
|
||||
}
|
||||
crm_events {
|
||||
int id PK
|
||||
varchar title
|
||||
@@ -103,7 +109,7 @@ erDiagram
|
||||
}
|
||||
```
|
||||
|
||||
Fünf Eigenheiten, die man kennen muss:
|
||||
Sechs Eigenheiten, die man kennen muss:
|
||||
|
||||
- **`crm_leads` führt zwei Bestände.** `mail_pool = 0` ist der gepflegte
|
||||
Kontaktbestand, `mail_pool = 1` der Vorbereich „Aus Mails" mit den Funden des
|
||||
@@ -120,6 +126,14 @@ Fünf 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.
|
||||
- **Die Dropdowns der Akquise stehen in einer Tabelle, nicht in vier.**
|
||||
`crm_acquisition_options` unterscheidet die Listen über die Spalte `field`. Die
|
||||
vier Felder verhalten sich gleich, also ist jede weitere Liste ein Wert im
|
||||
ENUM statt einer neuen Tabelle samt Route und Oberfläche. Der Unique-Schlüssel
|
||||
auf `(field, value)` greift über die Kollation `utf8mb4_unicode_ci` auch bei
|
||||
abweichender Groß-/Kleinschreibung - „hays" ist neben „Hays" kein neuer 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.
|
||||
- **`crm_acquisitions` hat keine Zustandsspalte.** Eingang, Archiv und Löschung
|
||||
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
|
||||
|
||||
@@ -88,16 +88,19 @@ weg.
|
||||
| Feld | wofür |
|
||||
| --- | --- |
|
||||
| **Datum** | wann gefunden. Vorbelegt mit heute; entscheidet später über Archiv und Löschung |
|
||||
| **Von** | wer es geschickt hat |
|
||||
| **Anbieter** | Vermittler oder Portal, z. B. Hays |
|
||||
| **Kanal** | Mail, Telefon, Portal, LinkedIn, XING, Empfehlung, Sonstiges |
|
||||
| **Von** | Auswahl: wer es geschickt hat |
|
||||
| **Anbieter** | Auswahl: Vermittler oder Portal, z. B. Hays |
|
||||
| **Kanal** | Auswahl: auf welchem Weg es kam |
|
||||
| **Projekttitel** | das einzige Pflichtfeld |
|
||||
| **Berater** | 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 |
|
||||
|
||||
*Von*, *Anbieter* und *Berater* schlagen vor, was schon einmal eingetragen wurde -
|
||||
antippen statt tippen. Beim Link genügt `hays.de/projekt/1`, das `https://` wird
|
||||
ergänzt.
|
||||
*Von*, *Anbieter*, *Kanal* und *Berater* sind **Auswahllisten**. Ihre Inhalte
|
||||
stehen unter *Einstellungen → Akquise* und lassen sich dort von jedem Benutzer
|
||||
pflegen - Freitext hätte aus „Hays", „hays" und „Hays AG" drei Anbieter gemacht.
|
||||
Ist eine Liste noch leer, steht unter dem Feld ein Verweis dorthin.
|
||||
|
||||
Beim Link genügt `hays.de/projekt/1`, das `https://` wird ergänzt.
|
||||
|
||||
Unter **Projektdaten für die Opportunity** klappt auf, was in der Liste keine
|
||||
Spalte hat, später aber in der Opportunity stehen soll: Endkunde, Projektstart
|
||||
@@ -561,10 +564,21 @@ Datenbank.
|
||||
| --- | --- | --- |
|
||||
| **Nutzerverwaltung** | Admin | Benutzer anlegen, bearbeiten, deaktivieren, löschen |
|
||||
| **Mein Konto** | alle | Foto, Terminfarbe, Passwort ändern |
|
||||
| **Akquise** | alle | Inhalte der Auswahllisten *Von*, *Anbieter*, *Kanal*, *Berater* |
|
||||
| **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktübernahme |
|
||||
| **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schlüssel, Ton, E-Mail |
|
||||
| **Whisper** | alle | Spracherkennung für das Diktat einrichten |
|
||||
|
||||
Im Reiter **Akquise** stehen die vier Auswahllisten des Akquise-Formulars, jede
|
||||
mit einem Feld zum Hinzufügen und je Eintrag einem Stift und einem Papierkorb.
|
||||
Zwei Dinge sind dabei unterschiedlich:
|
||||
|
||||
* **Umbenennen** ändert die vorhandenen Akquise-Einträge mit. Aus „Hays" wird in
|
||||
allen Einträgen „Hays AG"; die Meldung sagt, wie viele betroffen waren.
|
||||
* **Entfernen** nimmt den Wert nur aus der Auswahl. Einträge, die ihn tragen,
|
||||
behalten ihn und zeigen ihn im Formular als „nicht in der Liste" - so ändert
|
||||
das Aufräumen einer Liste keine Daten.
|
||||
|
||||
Im Reiter **Scanner** steht das **Suchintervall (Minuten)** - also wie oft der
|
||||
Hintergrund-Scanner die Börsen durchsucht.
|
||||
|
||||
|
||||
+10
-2
@@ -47,6 +47,11 @@ Mal etwas:
|
||||
* `migrateMailPool()` schiebt die vom Postfachabgleich angelegten Kontakte in den
|
||||
Vorbereich. Sie merkt sich das per Schalter in `crm_settings`, weil an den
|
||||
Daten selbst nicht zu erkennen ist, ob sie schon gelaufen ist.
|
||||
* `seedAcquisitionOptions()` legt den Startbestand der Akquise-Dropdowns an: die
|
||||
Kanäle, die vorher im Quelltext standen, plus alles, was in vorhandenen
|
||||
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
|
||||
entfernt hat.
|
||||
|
||||
`ensureAdminUser()` legt nur an, wenn `crm_users` **leer** ist. Ohne
|
||||
`ADMIN_PASSWORD` erzeugt es ein Zufallspasswort und schreibt es einmalig ins Log -
|
||||
@@ -83,7 +88,7 @@ Alle unter `router.use(requireAuth)`.
|
||||
| `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete |
|
||||
| `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin |
|
||||
| `opportunities.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | ändern/löschen nur Ersteller, Zuständiger oder Admin |
|
||||
| `acquisitions.js` | `GET /?view=eingang\|archiv\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id`, `POST /:id/opportunity` | ändern/löschen nur Ersteller oder Admin |
|
||||
| `acquisitions.js` | `GET /?view=eingang\|archiv\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id`, `POST /:id/opportunity`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern/löschen nur Ersteller oder Admin; Auswahllisten jeder Angemeldete |
|
||||
| `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 |
|
||||
|
||||
Wiederkehrende Muster in diesen Routern:
|
||||
@@ -259,6 +264,8 @@ Warum genau diese Konstanten festgelegt sind, steht in
|
||||
| `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/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/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 |
|
||||
| `components/leads/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Übernehmen, Sammelübernahme |
|
||||
@@ -283,7 +290,8 @@ nicht auseinanderlaufen:
|
||||
| `MAIL_LEAD_SOURCE`, `LeadOriginFilter` | Herkunft „Mail" in `Lead.source` und ihr Filter |
|
||||
| `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id |
|
||||
| `opportunityVolume()`, `weightedVolume()`, `formatEuro()` | Festpreis, sonst Tagessatz x Personentage; gewichtet; formatiert |
|
||||
| `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_KINDS`, `ACQUISITION_CHANNELS`, `CONNECTION_TYPES` | die Aufzählungen der Datenbank |
|
||||
| `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_KINDS`, `CONNECTION_TYPES` | die Aufzählungen der Datenbank |
|
||||
| `ACQUISITION_OPTION_FIELDS`, `ACQUISITION_FIELD_LABELS`, `ACQUISITION_FIELD_HINTS` | die vier Auswahllisten der Akquise samt Beschriftung |
|
||||
| `eventColor()`, `leadFullName()`, `leadInitials()`, `contactFullName()` | Anzeigelogik |
|
||||
|
||||
## Datenfluss im Frontend
|
||||
|
||||
@@ -22,6 +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. |
|
||||
| **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. |
|
||||
| **Auswahllisten** (Akquise) | Inhalte der Dropdowns *Von*, *Anbieter*, *Kanal*, *Berater*. Tabelle `crm_acquisition_options`, gepflegt unter *Einstellungen → Akquise*. |
|
||||
| **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. |
|
||||
| **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. |
|
||||
|
||||
@@ -50,6 +50,7 @@ export async function initDatabase() {
|
||||
|
||||
await migrateLeadKind();
|
||||
await migrateMailPool();
|
||||
await seedAcquisitionOptions();
|
||||
await ensureAdminUser();
|
||||
}
|
||||
|
||||
@@ -127,6 +128,49 @@ async function migrateMailPool() {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Startbestand der Akquise-Auswahllisten: die Kanaele, die bis zur Umstellung im
|
||||
* Quelltext standen, plus alles, was in vorhandenen Eintraegen schon als
|
||||
* Freitext steht.
|
||||
*
|
||||
* Laeuft genau einmal, festgehalten per Merker in crm_settings. Ohne den waere
|
||||
* jeder Neustart ein Wiederherstellen: ein Kanal, den jemand in den
|
||||
* Einstellungen bewusst entfernt hat, waere danach wieder da.
|
||||
*/
|
||||
async function seedAcquisitionOptions() {
|
||||
const flag = 'acquisition_options_seeded';
|
||||
const [rows] = await pool.query('SELECT value FROM crm_settings WHERE key_name = ? LIMIT 1', [flag]);
|
||||
if (rows[0]?.value === 'true') return;
|
||||
|
||||
const channels = ['Mail', 'Telefon', 'Portal', 'LinkedIn', 'XING', 'Empfehlung', 'Sonstiges'];
|
||||
await pool.query(
|
||||
`INSERT IGNORE INTO crm_acquisition_options (field, value)
|
||||
VALUES ${channels.map(() => "('channel', ?)").join(', ')}`,
|
||||
channels
|
||||
);
|
||||
|
||||
// Vorhandene Freitexte uebernehmen, damit kein Eintrag einen Wert traegt, den
|
||||
// sein Dropdown nicht kennt.
|
||||
for (const field of ['sender', 'provider', 'consultant', 'channel']) {
|
||||
await pool.query(
|
||||
`INSERT IGNORE INTO crm_acquisition_options (field, value)
|
||||
SELECT ?, TRIM(${field}) FROM crm_acquisitions
|
||||
WHERE ${field} IS NOT NULL AND TRIM(${field}) <> ''
|
||||
GROUP BY TRIM(${field})`,
|
||||
[field]
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO crm_settings (key_name, value) VALUES (?, 'true')
|
||||
ON DUPLICATE KEY UPDATE value = 'true'`,
|
||||
[flag]
|
||||
);
|
||||
|
||||
const [[count]] = await pool.query('SELECT COUNT(*) AS count FROM crm_acquisition_options');
|
||||
console.log(`[Akquise] Auswahllisten angelegt - ${count.count} Eintrag/Eintraege.`);
|
||||
}
|
||||
|
||||
async function ensureAdminUser() {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS count FROM crm_users');
|
||||
if (rows[0].count > 0) return;
|
||||
|
||||
@@ -24,15 +24,14 @@ const ARCHIVE_AFTER_DAYS = 14;
|
||||
/** Ab hier loescht das Archiv selbst. */
|
||||
const DELETE_AFTER_DAYS = 90;
|
||||
|
||||
const CHANNELS = new Set([
|
||||
'Mail',
|
||||
'Telefon',
|
||||
'Portal',
|
||||
'LinkedIn',
|
||||
'XING',
|
||||
'Empfehlung',
|
||||
'Sonstiges',
|
||||
]);
|
||||
/*
|
||||
* Felder, deren Inhalte als Auswahlliste in den Einstellungen gepflegt werden.
|
||||
* Die Werte selbst stehen in crm_acquisition_options, nicht hier - deshalb
|
||||
* prueft das Backend beim Speichern eines Eintrags nur die Laenge und nicht die
|
||||
* Zugehoerigkeit zur Liste: wer einen Wert aus der Liste entfernt, soll damit
|
||||
* nicht jeden alten Eintrag unaenderbar machen.
|
||||
*/
|
||||
const OPTION_FIELDS = ['sender', 'provider', 'channel', 'consultant'];
|
||||
|
||||
// `link` fehlt hier absichtlich: der Wert wird eigens geprueft (normalizeLink).
|
||||
const TEXT_FIELDS = [
|
||||
@@ -186,6 +185,16 @@ async function purgeOldEntries() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Alle Auswahllisten als { sender: [...], provider: [...], ... }. */
|
||||
async function loadOptions() {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, field, value FROM crm_acquisition_options ORDER BY field, value'
|
||||
);
|
||||
const options = { sender: [], provider: [], channel: [], consultant: [] };
|
||||
for (const row of rows) options[row.field].push({ id: row.id, value: row.value });
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Aendern und loeschen darf der Ersteller und jeder Admin. */
|
||||
function mayModify(user, row) {
|
||||
return user.role === 'admin' || row.created_by === user.id;
|
||||
@@ -215,8 +224,8 @@ function readFields(body, { partial }) {
|
||||
|
||||
if (!partial || body?.channel !== undefined) {
|
||||
const channel = emptyToNull(body?.channel);
|
||||
if (channel !== null && !CHANNELS.has(channel)) {
|
||||
return { error: 'Unbekannter Kanal.' };
|
||||
if (channel !== null && channel.length > 60) {
|
||||
return { error: 'Der Kanal ist zu lang (max. 60 Zeichen).' };
|
||||
}
|
||||
columns.channel = channel;
|
||||
}
|
||||
@@ -249,6 +258,130 @@ function readFields(body, { partial }) {
|
||||
return { columns };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auswahllisten (Einstellungen -> Akquise)
|
||||
//
|
||||
// Pflegen darf jeder Angemeldete. Wer ein Projekt eintraegt und dabei auf einen
|
||||
// neuen Anbieter stoesst, muss weiterarbeiten koennen - eine Liste, die nur ein
|
||||
// Administrator ergaenzen kann, blockiert genau dann die Eingabe.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/acquisitions/options
|
||||
router.get('/options', async (req, res, next) => {
|
||||
try {
|
||||
res.json(await loadOptions());
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/acquisitions/options
|
||||
router.post('/options', async (req, res, next) => {
|
||||
const field = String(req.body?.field ?? '');
|
||||
if (!OPTION_FIELDS.includes(field)) return res.status(400).json({ error: 'Unbekanntes Feld.' });
|
||||
|
||||
const value = emptyToNull(req.body?.value);
|
||||
if (!value) return res.status(400).json({ error: 'Ein Wert ist erforderlich.' });
|
||||
const limit = field === 'channel' ? 60 : 190;
|
||||
if (value.length > limit) {
|
||||
return res.status(400).json({ error: `Der Wert ist zu lang (max. ${limit} Zeichen).` });
|
||||
}
|
||||
|
||||
try {
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO crm_acquisition_options (field, value, created_by) VALUES (?, ?, ?)',
|
||||
[field, value, req.user.id]
|
||||
);
|
||||
res.status(201).json({ id: result.insertId, field, value });
|
||||
} catch (err) {
|
||||
if (err?.code === 'ER_DUP_ENTRY') {
|
||||
return res.status(409).json({ error: 'Dieser Wert steht schon in der Liste.' });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* PATCH /api/acquisitions/options/:id
|
||||
*
|
||||
* Umbenennen zieht die vorhandenen Eintraege mit: wird "Hays" zu "Hays AG", soll
|
||||
* die Liste nicht auf einmal zwei Anbieter zeigen, von denen einer in keinem
|
||||
* Dropdown mehr steht.
|
||||
*/
|
||||
router.patch('/options/:id', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Options-ID.' });
|
||||
|
||||
const value = emptyToNull(req.body?.value);
|
||||
if (!value) return res.status(400).json({ error: 'Ein Wert ist erforderlich.' });
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.query(
|
||||
'SELECT field, value FROM crm_acquisition_options WHERE id = ? LIMIT 1',
|
||||
[id]
|
||||
);
|
||||
const existing = rows[0];
|
||||
if (!existing) return res.status(404).json({ error: 'Eintrag der Liste nicht gefunden.' });
|
||||
|
||||
const limit = existing.field === 'channel' ? 60 : 190;
|
||||
if (value.length > limit) {
|
||||
return res.status(400).json({ error: `Der Wert ist zu lang (max. ${limit} Zeichen).` });
|
||||
}
|
||||
if (value === existing.value) return res.json({ id, field: existing.field, value, updated: 0 });
|
||||
|
||||
await conn.beginTransaction();
|
||||
await conn.query('UPDATE crm_acquisition_options SET value = ? WHERE id = ?', [value, id]);
|
||||
const [updated] = await conn.query(
|
||||
`UPDATE crm_acquisitions SET ${existing.field} = ? WHERE ${existing.field} = ?`,
|
||||
[value, existing.value]
|
||||
);
|
||||
await conn.commit();
|
||||
|
||||
res.json({ id, field: existing.field, value, updated: updated.affectedRows });
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {});
|
||||
if (err?.code === 'ER_DUP_ENTRY') {
|
||||
return res.status(409).json({ error: 'Dieser Wert steht schon in der Liste.' });
|
||||
}
|
||||
next(err);
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* DELETE /api/acquisitions/options/:id
|
||||
*
|
||||
* Nur die Auswahl verschwindet. Die Eintraege behalten ihren Wert - sonst wuerde
|
||||
* das Aufraeumen einer Liste stillschweigend Daten aendern. Wie viele Eintraege
|
||||
* betroffen sind, steht in der Antwort, damit die Oberflaeche es sagen kann.
|
||||
*/
|
||||
router.delete('/options/:id', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Options-ID.' });
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT field, value FROM crm_acquisition_options WHERE id = ? LIMIT 1',
|
||||
[id]
|
||||
);
|
||||
const existing = rows[0];
|
||||
if (!existing) return res.status(404).json({ error: 'Eintrag der Liste nicht gefunden.' });
|
||||
|
||||
const [[used]] = await pool.query(
|
||||
`SELECT COUNT(*) AS count FROM crm_acquisitions
|
||||
WHERE converted_at IS NULL AND ${existing.field} = ?`,
|
||||
[existing.value]
|
||||
);
|
||||
|
||||
await pool.query('DELETE FROM crm_acquisition_options WHERE id = ?', [id]);
|
||||
res.json({ ok: true, still_in_use: Number(used.count) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/acquisitions?view=eingang|archiv|alle
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
@@ -288,31 +421,12 @@ router.get('/', async (req, res, next) => {
|
||||
[ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS]
|
||||
);
|
||||
|
||||
/*
|
||||
* Vorschlaege fuer die Eingabe, aus dem gesamten Bestand - nicht nur aus der
|
||||
* gerade gezeigten Liste. Wer denselben Anbieter zum zwanzigsten Mal
|
||||
* eintraegt, soll ihn nicht zum zwanzigsten Mal ausschreiben muessen.
|
||||
*/
|
||||
const [suggestionRows] = await pool.query(
|
||||
// Klammern sind Pflicht: ORDER BY und LIMIT in einem UNION-Zweig gehoeren
|
||||
// sonst zur Gesamtabfrage.
|
||||
`(SELECT 'provider' AS field, provider AS value FROM crm_acquisitions
|
||||
WHERE provider IS NOT NULL GROUP BY provider ORDER BY MAX(found_at) DESC LIMIT 60)
|
||||
UNION ALL
|
||||
(SELECT 'consultant', consultant FROM crm_acquisitions
|
||||
WHERE consultant IS NOT NULL GROUP BY consultant ORDER BY MAX(found_at) DESC LIMIT 60)
|
||||
UNION ALL
|
||||
(SELECT 'sender', sender FROM crm_acquisitions
|
||||
WHERE sender IS NOT NULL GROUP BY sender ORDER BY MAX(found_at) DESC LIMIT 60)`
|
||||
);
|
||||
const suggestions = { providers: [], consultants: [], senders: [] };
|
||||
const bucket = { provider: 'providers', consultant: 'consultants', sender: 'senders' };
|
||||
for (const row of suggestionRows) suggestions[bucket[row.field]].push(row.value);
|
||||
|
||||
// Die Auswahllisten kommen mit: die Eingabe braucht sie sofort, und ein
|
||||
// zweiter Aufruf nur fuer vier kurze Listen waere verschenkt.
|
||||
res.json({
|
||||
archive_after_days: ARCHIVE_AFTER_DAYS,
|
||||
delete_after_days: DELETE_AFTER_DAYS,
|
||||
suggestions,
|
||||
options: await loadOptions(),
|
||||
counts: {
|
||||
eingang: Number(counts.eingang ?? 0),
|
||||
archiv: Number(counts.archiv ?? 0),
|
||||
|
||||
@@ -397,3 +397,26 @@ CREATE TABLE IF NOT EXISTS crm_acquisitions (
|
||||
CONSTRAINT fk_crm_acq_opp FOREIGN KEY (opportunity_id) REFERENCES crm_opportunities (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_crm_acq_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
/*
|
||||
* Auswahllisten der Akquise. "Von", "Anbieter", "Kanal" und "Berater" sind
|
||||
* Dropdowns, deren Inhalte in den Einstellungen gepflegt werden - Freitext hat
|
||||
* aus "Hays", "hays" und "Hays AG" drei Anbieter gemacht.
|
||||
*
|
||||
* Bewusst eine Tabelle mit Feldkennzeichen statt vier Tabellen: die Felder
|
||||
* verhalten sich gleich, und jede weitere Liste ist damit ein Wert im ENUM statt
|
||||
* einer neuen Tabelle samt Route.
|
||||
*
|
||||
* Der Unique-Schluessel greift ueber die Kollation utf8mb4_unicode_ci auch bei
|
||||
* abweichender Gross-/Kleinschreibung: "hays" ist neben "Hays" kein neuer Wert.
|
||||
*/
|
||||
CREATE TABLE IF NOT EXISTS crm_acquisition_options (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
field ENUM('sender','provider','channel','consultant') NOT NULL,
|
||||
value VARCHAR(190) NOT NULL,
|
||||
created_by INT UNSIGNED NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_crm_acq_opt (field, value),
|
||||
CONSTRAINT fk_crm_acq_opt_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { DictateTextarea } from '../ui/DictationField';
|
||||
import { inputClass, labelClass } from '../leads/leadUi';
|
||||
import { OptionSelect, fieldClass } from './acquisitionUi';
|
||||
import { labelClass } from '../leads/leadUi';
|
||||
import { useToast } from '../Toast';
|
||||
import { useNavigation } from '../../lib/navigation';
|
||||
import { acquisitionsApi, type AcquisitionInput } from '../../lib/api';
|
||||
import {
|
||||
ACQUISITION_CHANNELS,
|
||||
ACQUISITION_FIELD_LABELS,
|
||||
formatEuro,
|
||||
type Acquisition,
|
||||
type AcquisitionChannel,
|
||||
type AcquisitionOptions,
|
||||
} from '../../types';
|
||||
|
||||
/*
|
||||
@@ -23,7 +25,8 @@ import {
|
||||
interface AcquisitionFormProps {
|
||||
/** Gesetzt = Bearbeiten, sonst Neuanlage. */
|
||||
editing: Acquisition | null;
|
||||
suggestions: { providers: string[]; consultants: string[]; senders: string[] };
|
||||
/** Inhalte der Dropdowns, gepflegt unter Einstellungen -> Akquise. */
|
||||
options: AcquisitionOptions;
|
||||
onSaved: () => void;
|
||||
onCancelEdit: () => void;
|
||||
}
|
||||
@@ -74,42 +77,15 @@ function toFormState(entry: Acquisition | null): FormState {
|
||||
};
|
||||
}
|
||||
|
||||
/** Freitextfeld mit Vorschlagsliste aus den bisherigen Eintraegen. */
|
||||
const SuggestInput: React.FC<{
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
}> = ({ id, label, value, options, placeholder, onChange }) => (
|
||||
<div>
|
||||
<label className={labelClass} htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
list={`${id}-vorschlaege`}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputClass}
|
||||
/>
|
||||
<datalist id={`${id}-vorschlaege`}>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
||||
editing,
|
||||
suggestions,
|
||||
options,
|
||||
onSaved,
|
||||
onCancelEdit,
|
||||
}) => {
|
||||
const { addToast } = useToast();
|
||||
const { goTo } = useNavigation();
|
||||
const openSettings = () => goTo('Einstellungen', { tab: 'akquise' });
|
||||
const [form, setForm] = useState<FormState>(() => toFormState(editing));
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -172,7 +148,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
||||
found_at: form.found_at || today(),
|
||||
sender: form.sender.trim() || null,
|
||||
provider: form.provider.trim() || null,
|
||||
channel: (form.channel as AcquisitionChannel) || null,
|
||||
channel: form.channel || null,
|
||||
title: form.title.trim(),
|
||||
consultant: form.consultant.trim() || null,
|
||||
link: form.link.trim() || null,
|
||||
@@ -265,43 +241,33 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
||||
type="date"
|
||||
value={form.found_at}
|
||||
onChange={(event) => set('found_at', event.target.value)}
|
||||
className={inputClass}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<SuggestInput
|
||||
<OptionSelect
|
||||
id="acq-sender"
|
||||
label="Von"
|
||||
label={ACQUISITION_FIELD_LABELS.sender}
|
||||
value={form.sender}
|
||||
options={suggestions.senders}
|
||||
placeholder="Wer hat es geschickt?"
|
||||
options={options.sender}
|
||||
onChange={(value) => set('sender', value)}
|
||||
onOpenSettings={openSettings}
|
||||
/>
|
||||
<SuggestInput
|
||||
<OptionSelect
|
||||
id="acq-provider"
|
||||
label="Anbieter"
|
||||
label={ACQUISITION_FIELD_LABELS.provider}
|
||||
value={form.provider}
|
||||
options={suggestions.providers}
|
||||
placeholder="z. B. Hays"
|
||||
options={options.provider}
|
||||
onChange={(value) => set('provider', value)}
|
||||
onOpenSettings={openSettings}
|
||||
/>
|
||||
<OptionSelect
|
||||
id="acq-channel"
|
||||
label={ACQUISITION_FIELD_LABELS.channel}
|
||||
value={form.channel}
|
||||
options={options.channel}
|
||||
onChange={(value) => set('channel', value)}
|
||||
onOpenSettings={openSettings}
|
||||
/>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="acq-channel">
|
||||
Kanal
|
||||
</label>
|
||||
<select
|
||||
id="acq-channel"
|
||||
value={form.channel}
|
||||
onChange={(event) => set('channel', event.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{ACQUISITION_CHANNELS.map((channel) => (
|
||||
<option key={channel} value={channel}>
|
||||
{channel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-[2fr_1fr_2fr]">
|
||||
@@ -316,16 +282,16 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
||||
value={form.title}
|
||||
onChange={(event) => set('title', event.target.value)}
|
||||
placeholder="z. B. SAP FI Rollout Süddeutschland"
|
||||
className={inputClass}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<SuggestInput
|
||||
<OptionSelect
|
||||
id="acq-consultant"
|
||||
label="Berater"
|
||||
label={ACQUISITION_FIELD_LABELS.consultant}
|
||||
value={form.consultant}
|
||||
options={suggestions.consultants}
|
||||
placeholder="für wen?"
|
||||
options={options.consultant}
|
||||
onChange={(value) => set('consultant', value)}
|
||||
onOpenSettings={openSettings}
|
||||
/>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="acq-link">
|
||||
@@ -340,7 +306,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
||||
value={form.link}
|
||||
onChange={(event) => set('link', event.target.value)}
|
||||
placeholder="hays.de/projekt/1 - macht den Titel in der Liste klickbar"
|
||||
className={inputClass}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -376,7 +342,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
||||
value={form.company}
|
||||
onChange={(event) => set('company', event.target.value)}
|
||||
placeholder="Firma hinter dem Projekt"
|
||||
className={inputClass}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -388,7 +354,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
||||
type="date"
|
||||
value={form.start_date}
|
||||
onChange={(event) => set('start_date', event.target.value)}
|
||||
className={inputClass}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -400,7 +366,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
||||
type="date"
|
||||
value={form.end_date}
|
||||
onChange={(event) => set('end_date', event.target.value)}
|
||||
className={inputClass}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -416,7 +382,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
||||
value={form.daily_rate}
|
||||
onChange={(event) => set('daily_rate', event.target.value)}
|
||||
placeholder="1150"
|
||||
className={inputClass}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -429,7 +395,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
||||
value={form.duration_days}
|
||||
onChange={(event) => set('duration_days', event.target.value)}
|
||||
placeholder="40"
|
||||
className={inputClass}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -442,7 +408,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
||||
value={form.fixed_price}
|
||||
onChange={(event) => set('fixed_price', event.target.value)}
|
||||
placeholder="Festpreis statt Tagessatz × Laufzeit"
|
||||
className={inputClass}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -472,7 +438,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
||||
value={form.next_step}
|
||||
onChange={(event) => set('next_step', event.target.value)}
|
||||
placeholder="z. B. Profil bis Donnerstag schicken"
|
||||
className={inputClass}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ConfirmDialog } from '../ui/Modal';
|
||||
import { useToast } from '../Toast';
|
||||
import { useNavigation } from '../../lib/navigation';
|
||||
import { acquisitionsApi } from '../../lib/api';
|
||||
import type { Acquisition, AcquisitionList } from '../../types';
|
||||
import { EMPTY_ACQUISITION_OPTIONS, type Acquisition, type AcquisitionList } from '../../types';
|
||||
|
||||
/*
|
||||
* Akquise: gefundene Projekte in einer Liste, mit der Eingabe darueber.
|
||||
@@ -174,7 +174,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
||||
) : (
|
||||
<AcquisitionForm
|
||||
editing={editing}
|
||||
suggestions={data?.suggestions ?? { providers: [], consultants: [], senders: [] }}
|
||||
options={data?.options ?? EMPTY_ACQUISITION_OPTIONS}
|
||||
onSaved={() => load(true)}
|
||||
onCancelEdit={() => setEditing(null)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { labelClass } from '../leads/leadUi';
|
||||
import type { AcquisitionOption } from '../../types';
|
||||
|
||||
/*
|
||||
* Gemeinsame Bausteine der Akquise.
|
||||
*
|
||||
* `fieldClass` ist die abgesetzte Variante von `inputClass`: leicht getoente
|
||||
* Flaeche und ein deutlicherer Rahmen. Auf der weissen Karte der Eingabe gingen
|
||||
* die Felder sonst unter - im Formular stehen bis zu fuenfzehn davon
|
||||
* nebeneinander, und man muss sehen, wo eines anfaengt und aufhoert. Beim
|
||||
* Hineinklicken wird das Feld weiss, damit klar ist, wo man schreibt.
|
||||
*/
|
||||
export const fieldClass =
|
||||
'w-full rounded-xl border border-ink-300 bg-ink-50 px-4 py-2.5 text-sm text-ink-900 outline-none transition hover:border-ink-400 focus:border-brand-500 focus:bg-surface focus:ring-2 focus:ring-brand-500/25';
|
||||
|
||||
interface OptionSelectProps {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
options: AcquisitionOption[];
|
||||
onChange: (value: string) => void;
|
||||
/** Fuehrt zu Einstellungen -> Akquise, wo die Liste gepflegt wird. */
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auswahlfeld fuer "Von", "Anbieter", "Kanal" und "Berater".
|
||||
*
|
||||
* Steht im Eintrag ein Wert, den die Liste nicht (mehr) kennt, wird er zusaetzlich
|
||||
* angeboten und als solcher gekennzeichnet. Ohne das wuerde das Bearbeiten eines
|
||||
* alten Eintrags den Wert stillschweigend auf den ersten Listeneintrag aendern.
|
||||
*/
|
||||
export const OptionSelect: React.FC<OptionSelectProps> = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
onOpenSettings,
|
||||
}) => {
|
||||
const isUnlisted = value !== '' && !options.some((option) => option.value === value);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className={labelClass} htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className={fieldClass}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.id} value={option.value}>
|
||||
{option.value}
|
||||
</option>
|
||||
))}
|
||||
{isUnlisted && <option value={value}>{value} (nicht in der Liste)</option>}
|
||||
</select>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { fieldClass } from '../acquisition/acquisitionUi';
|
||||
import { labelClass } from '../leads/leadUi';
|
||||
import { ConfirmDialog } from '../ui/Modal';
|
||||
import { useToast } from '../Toast';
|
||||
import { acquisitionOptionsApi } from '../../lib/api';
|
||||
import {
|
||||
ACQUISITION_FIELD_HINTS,
|
||||
ACQUISITION_FIELD_LABELS,
|
||||
ACQUISITION_OPTION_FIELDS,
|
||||
EMPTY_ACQUISITION_OPTIONS,
|
||||
type AcquisitionOption,
|
||||
type AcquisitionOptionField,
|
||||
type AcquisitionOptions,
|
||||
} from '../../types';
|
||||
|
||||
/*
|
||||
* Inhalte der Akquise-Dropdowns pflegen: "Von", "Anbieter", "Kanal", "Berater".
|
||||
*
|
||||
* Vier gleich aufgebaute Listen statt vier Sonderfaelle - die Felder verhalten
|
||||
* sich gleich, also sieht ihre Pflege auch gleich aus.
|
||||
*
|
||||
* Umbenennen zieht die vorhandenen Akquise-Eintraege mit, Loeschen nicht: eine
|
||||
* Liste aufzuraeumen darf keine Daten aendern. Beide Antworten sagen, wie viele
|
||||
* Eintraege betroffen sind, und das steht dann auch in der Meldung.
|
||||
*/
|
||||
|
||||
const PlusIcon = (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const OptionRow: React.FC<{
|
||||
option: AcquisitionOption;
|
||||
onRenamed: (value: string) => Promise<void>;
|
||||
onDelete: () => void;
|
||||
}> = ({ option, onRenamed, onDelete }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(option.value);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Wird der Wert von aussen umbenannt (oder die Liste neu geladen), soll der
|
||||
// Entwurf nicht auf dem alten Stand stehen bleiben.
|
||||
useEffect(() => {
|
||||
setDraft(option.value);
|
||||
}, [option.value]);
|
||||
|
||||
const save = async () => {
|
||||
const value = draft.trim();
|
||||
if (value === '' || value === option.value) {
|
||||
setDraft(option.value);
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onRenamed(value);
|
||||
setIsEditing(false);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-2 rounded-xl border border-ink-200 bg-surface px-3 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
disabled={isSaving}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
save();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
setDraft(option.value);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
onBlur={save}
|
||||
aria-label={`${option.value} umbenennen`}
|
||||
className={`${fieldClass} py-1.5`}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-ink-800">
|
||||
{option.value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
title="Umbenennen"
|
||||
aria-label={`${option.value} umbenennen`}
|
||||
className="shrink-0 rounded-lg p-1.5 text-ink-400 transition hover:bg-brand-50 hover:text-brand-700"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
title="Aus der Liste entfernen"
|
||||
aria-label={`${option.value} entfernen`}
|
||||
className="shrink-0 rounded-lg p-1.5 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const AcquisitionSettings: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
const [options, setOptions] = useState<AcquisitionOptions>(EMPTY_ACQUISITION_OPTIONS);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [drafts, setDrafts] = useState<Record<AcquisitionOptionField, string>>({
|
||||
sender: '',
|
||||
provider: '',
|
||||
channel: '',
|
||||
consultant: '',
|
||||
});
|
||||
const [toDelete, setToDelete] = useState<{
|
||||
field: AcquisitionOptionField;
|
||||
option: AcquisitionOption;
|
||||
} | null>(null);
|
||||
|
||||
const load = async (silent = false) => {
|
||||
if (!silent) setIsLoading(true);
|
||||
try {
|
||||
setOptions(await acquisitionOptionsApi.list());
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Auswahllisten nicht abrufbar.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// Einmal beim Oeffnen des Reiters.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const add = async (field: AcquisitionOptionField) => {
|
||||
const value = drafts[field].trim();
|
||||
if (!value) return;
|
||||
try {
|
||||
await acquisitionOptionsApi.create(field, value);
|
||||
setDrafts((prev) => ({ ...prev, [field]: '' }));
|
||||
addToast(`„${value}" zu ${ACQUISITION_FIELD_LABELS[field]} hinzugefügt.`, 'success');
|
||||
await load(true);
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Hinzufügen fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const rename = async (option: AcquisitionOption, value: string) => {
|
||||
try {
|
||||
const result = await acquisitionOptionsApi.update(option.id, value);
|
||||
addToast(
|
||||
result.updated > 0
|
||||
? `Umbenannt - ${result.updated} Eintrag/Einträge in der Akquise mitgeändert.`
|
||||
: 'Umbenannt.',
|
||||
'success'
|
||||
);
|
||||
await load(true);
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Umbenennen fehlgeschlagen.', 'error');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!toDelete) return;
|
||||
try {
|
||||
const result = await acquisitionOptionsApi.remove(toDelete.option.id);
|
||||
addToast(
|
||||
result.still_in_use > 0
|
||||
? `Aus der Liste entfernt. ${result.still_in_use} Eintrag/Einträge behalten den Wert.`
|
||||
: 'Aus der Liste entfernt.',
|
||||
'success'
|
||||
);
|
||||
await load(true);
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Entfernen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
|
||||
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-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>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
Diese vier Listen füllen die Dropdowns im Formular unter <em>Akquise</em>. Feste Auswahl
|
||||
statt Freitext, damit aus „Hays", „hays" und „Hays AG" nicht drei Anbieter werden.
|
||||
Pflegen darf jeder angemeldete Benutzer - wer beim Eintragen auf einen neuen Anbieter
|
||||
stößt, soll weiterarbeiten können.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-ink-400">
|
||||
<strong>Umbenennen</strong> ändert die vorhandenen Akquise-Einträge mit.
|
||||
<strong> Entfernen</strong> nimmt den Wert nur aus der Auswahl - Einträge, die ihn
|
||||
tragen, behalten ihn und zeigen ihn als „nicht in der Liste".
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
{ACQUISITION_OPTION_FIELDS.map((field) => (
|
||||
<section key={field} className="rounded-2xl border border-ink-200 bg-surface p-5">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-base text-ink-900">{ACQUISITION_FIELD_LABELS[field]}</h3>
|
||||
<span className="text-[11px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||
{options[field].length} {options[field].length === 1 ? 'Eintrag' : 'Einträge'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-ink-400">{ACQUISITION_FIELD_HINTS[field]}</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className={labelClass} htmlFor={`opt-new-${field}`}>
|
||||
Hinzufügen
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id={`opt-new-${field}`}
|
||||
value={drafts[field]}
|
||||
onChange={(event) =>
|
||||
setDrafts((prev) => ({ ...prev, [field]: event.target.value }))
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
add(field);
|
||||
}
|
||||
}}
|
||||
placeholder={`Neuer Wert für ${ACQUISITION_FIELD_LABELS[field]}`}
|
||||
className={fieldClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => add(field)}
|
||||
disabled={!drafts[field].trim()}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-xl bg-brand-500 px-4 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50"
|
||||
>
|
||||
{PlusIcon}
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{options[field].length === 0 ? (
|
||||
<p className="mt-4 rounded-xl border border-dashed border-ink-200 px-4 py-6 text-center text-xs font-semibold text-ink-400">
|
||||
Noch nichts hinterlegt - das Dropdown ist dann leer.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-4 space-y-2">
|
||||
{options[field].map((option) => (
|
||||
<OptionRow
|
||||
key={option.id}
|
||||
option={option}
|
||||
onRenamed={(value) => rename(option, value)}
|
||||
onDelete={() => setToDelete({ field, option })}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{toDelete && (
|
||||
<ConfirmDialog
|
||||
title={`„${toDelete.option.value}" entfernen?`}
|
||||
message={`Der Wert verschwindet aus der Auswahl ${
|
||||
ACQUISITION_FIELD_LABELS[toDelete.field]
|
||||
}. Vorhandene Akquise-Einträge behalten ihn - sie lassen sich weiter bearbeiten und übernehmen.`}
|
||||
confirmLabel="Entfernen"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setToDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { UserManagement } from './UserManagement';
|
||||
import { MailSettings } from './MailSettings';
|
||||
import { AcquisitionSettings } from './AcquisitionSettings';
|
||||
import { ScannerSettings } from './ScannerSettings';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
import { AvatarPicker } from '../ui/AvatarPicker';
|
||||
@@ -14,6 +15,7 @@ import { ADMIN_SETTINGS_TABS, userColor, type SettingsTab } from '../../types';
|
||||
const TABS: { id: SettingsTab; label: string }[] = [
|
||||
{ id: 'users', label: 'Nutzerverwaltung' },
|
||||
{ id: 'account', label: 'Mein Konto' },
|
||||
{ id: 'akquise', label: 'Akquise' },
|
||||
{ id: 'mail', label: 'Postfach' },
|
||||
{ id: 'scanner', label: 'Scanner' },
|
||||
{ id: 'whisper', label: 'Whisper' },
|
||||
@@ -178,7 +180,9 @@ const AccountTab: React.FC = () => {
|
||||
export const Settings: React.FC<{ initialTab?: SettingsTab }> = ({ initialTab }) => {
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
// Nutzerverwaltung und Scanner nur fuer Admins; Konto und Whisper fuer alle.
|
||||
// Nutzerverwaltung, Postfach und Scanner nur fuer Admins; Konto, Akquise und
|
||||
// Whisper fuer alle - die Akquise-Auswahllisten sind Arbeitsmittel, keine
|
||||
// Konfiguration, und wer eintraegt muss sie ergaenzen koennen.
|
||||
const tabs = TABS.filter((item) => isAdmin || !ADMIN_SETTINGS_TABS.includes(item.id));
|
||||
const defaultTab: SettingsTab = isAdmin ? 'users' : 'account';
|
||||
|
||||
@@ -217,6 +221,7 @@ export const Settings: React.FC<{ initialTab?: SettingsTab }> = ({ initialTab })
|
||||
|
||||
{tab === 'users' && isAdmin && <UserManagement />}
|
||||
{tab === 'account' && <AccountTab />}
|
||||
{tab === 'akquise' && <AcquisitionSettings />}
|
||||
{tab === 'mail' && isAdmin && <MailSettings />}
|
||||
{tab === 'scanner' && isAdmin && <ScannerSettings />}
|
||||
{tab === 'whisper' && <WhisperSettings />}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type {
|
||||
Acquisition,
|
||||
AcquisitionList,
|
||||
AcquisitionOption,
|
||||
AcquisitionOptionField,
|
||||
AcquisitionOptions,
|
||||
AppUser,
|
||||
CalendarEvent,
|
||||
Lead,
|
||||
@@ -245,6 +248,32 @@ export const acquisitionsApi = {
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Inhalte der Dropdowns "Von", "Anbieter", "Kanal" und "Berater". Pflegen darf
|
||||
* jeder Angemeldete: eine Liste, die nur ein Administrator ergaenzen kann, wuerde
|
||||
* die Eingabe genau dann blockieren, wenn ein neuer Anbieter auftaucht.
|
||||
*/
|
||||
export const acquisitionOptionsApi = {
|
||||
list: () => request<AcquisitionOptions>('/acquisitions/options'),
|
||||
|
||||
create: (field: AcquisitionOptionField, value: string) =>
|
||||
request<AcquisitionOption & { field: AcquisitionOptionField }>('/acquisitions/options', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ field, value }),
|
||||
}),
|
||||
|
||||
/** Umbenennen zieht die vorhandenen Akquise-Eintraege mit; `updated` sagt wie viele. */
|
||||
update: (id: number, value: string) =>
|
||||
request<AcquisitionOption & { field: AcquisitionOptionField; updated: number }>(
|
||||
`/acquisitions/options/${id}`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ value }) }
|
||||
),
|
||||
|
||||
/** 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' }),
|
||||
};
|
||||
|
||||
// ---------- Interviews (Termine) ----------
|
||||
|
||||
export interface EventInput {
|
||||
|
||||
+39
-16
@@ -21,7 +21,7 @@ export type ViewState =
|
||||
* `account` und `whisper` stehen jedem offen - jeder pflegt sein eigenes Konto
|
||||
* und richtet sein eigenes Diktat ein.
|
||||
*/
|
||||
export type SettingsTab = 'users' | 'account' | 'mail' | 'scanner' | 'whisper';
|
||||
export type SettingsTab = 'users' | 'account' | 'akquise' | 'mail' | 'scanner' | 'whisper';
|
||||
|
||||
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'scanner'];
|
||||
|
||||
@@ -251,20 +251,43 @@ export type OpportunityViewMode = 'grid' | 'list' | 'pipeline';
|
||||
// ---------- Akquise ----------
|
||||
|
||||
/**
|
||||
* Wie das Projekt zu uns kam. Feste Auswahl, damit sich die Liste nach dem Kanal
|
||||
* gruppieren laesst - als Freitext waeren "Mail", "E-Mail" und "mail" drei Kanaele.
|
||||
* Felder der Akquise, die aus einer Auswahlliste kommen. Die Werte stehen in der
|
||||
* Datenbank (crm_acquisition_options) und werden unter Einstellungen -> Akquise
|
||||
* gepflegt - als Freitext waeren "Hays", "hays" und "Hays AG" drei Anbieter.
|
||||
*/
|
||||
export const ACQUISITION_CHANNELS = [
|
||||
'Mail',
|
||||
'Telefon',
|
||||
'Portal',
|
||||
'LinkedIn',
|
||||
'XING',
|
||||
'Empfehlung',
|
||||
'Sonstiges',
|
||||
] as const;
|
||||
export const ACQUISITION_OPTION_FIELDS = ['sender', 'provider', 'channel', 'consultant'] as const;
|
||||
|
||||
export type AcquisitionChannel = (typeof ACQUISITION_CHANNELS)[number];
|
||||
export type AcquisitionOptionField = (typeof ACQUISITION_OPTION_FIELDS)[number];
|
||||
|
||||
/** Beschriftung der Felder - dieselbe in der Liste, im Formular und in den Einstellungen. */
|
||||
export const ACQUISITION_FIELD_LABELS: Record<AcquisitionOptionField, string> = {
|
||||
sender: 'Von',
|
||||
provider: 'Anbieter',
|
||||
channel: 'Kanal',
|
||||
consultant: 'Berater',
|
||||
};
|
||||
|
||||
/** Was in der jeweiligen Liste steht - als Hilfe in den Einstellungen. */
|
||||
export const ACQUISITION_FIELD_HINTS: Record<AcquisitionOptionField, string> = {
|
||||
sender: 'Personen, von denen Projekte kommen',
|
||||
provider: 'Vermittler und Portale, z. B. Hays',
|
||||
channel: 'Auf welchem Weg das Projekt kam',
|
||||
consultant: 'Eigene Berater, für die Projekte gesucht werden',
|
||||
};
|
||||
|
||||
export interface AcquisitionOption {
|
||||
id: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type AcquisitionOptions = Record<AcquisitionOptionField, AcquisitionOption[]>;
|
||||
|
||||
export const EMPTY_ACQUISITION_OPTIONS: AcquisitionOptions = {
|
||||
sender: [],
|
||||
provider: [],
|
||||
channel: [],
|
||||
consultant: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Ein gefundenes Projekt, bevor daraus eine Opportunity wird.
|
||||
@@ -280,7 +303,7 @@ export interface Acquisition {
|
||||
sender: string | null;
|
||||
/** "Anbieter": Vermittler oder Portal. */
|
||||
provider: string | null;
|
||||
channel: AcquisitionChannel | null;
|
||||
channel: string | null;
|
||||
title: string;
|
||||
/** "Berater", fuer den es passt. */
|
||||
consultant: string | null;
|
||||
@@ -312,8 +335,8 @@ export interface AcquisitionList {
|
||||
archive_after_days: number;
|
||||
delete_after_days: number;
|
||||
counts: { eingang: number; archiv: number };
|
||||
/** Bisher benutzte Werte aus dem gesamten Bestand, fuer die Eingabehilfen. */
|
||||
suggestions: { providers: string[]; consultants: string[]; senders: string[] };
|
||||
/** Inhalte der Dropdowns; kommen mit, damit die Eingabe sie sofort hat. */
|
||||
options: AcquisitionOptions;
|
||||
entries: Acquisition[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user