feat(akquise): Mail an Berater bei neuem Projekt
Wird in der Akquise ein Projekt mit Berater angelegt (oder ein Berater beim Bearbeiten hinzugefuegt), bekommt er eine Mail ueber Graph sendMail aus crm@perinco.de; Reply-To ist der eintragende Benutzer. Je Berater abschaltbar unter Einstellungen -> Akquise (Spalte notify, Vorgabe an). Versand laeuft nach der Antwort und kippt das Speichern nie. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
907b010f3c
commit
eaef454442
@@ -104,6 +104,7 @@ erDiagram
|
|||||||
enum field "sender | channel | consultant"
|
enum field "sender | channel | consultant"
|
||||||
varchar value "Inhalt des Dropdowns"
|
varchar value "Inhalt des Dropdowns"
|
||||||
varchar email "nur bei consultant"
|
varchar email "nur bei consultant"
|
||||||
|
tinyint notify "Mail bei neuem Projekt, Vorgabe 1"
|
||||||
}
|
}
|
||||||
crm_acquisition_history {
|
crm_acquisition_history {
|
||||||
date found_on PK
|
date found_on PK
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ entfallen.
|
|||||||
| Ton bleibt aus | Schwellenwert, Dashboard-Ansicht offen, einmal auf der Seite geklickt? |
|
| Ton bleibt aus | Schwellenwert, Dashboard-Ansicht offen, einmal auf der Seite geklickt? |
|
||||||
| Keine Mails im Postfachreiter | `journalctl -u perinco-crm-backend \| grep '\[Mail\]'`, dann Verbindungstest in den Einstellungen |
|
| Keine Mails im Postfachreiter | `journalctl -u perinco-crm-backend \| grep '\[Mail\]'`, dann Verbindungstest in den Einstellungen |
|
||||||
| Mail-Abgleich meldet `ErrorAccessDenied` | Administratorzustimmung für `Mail.Read` fehlt, oder die ApplicationAccessPolicy erlaubt das Postfach nicht |
|
| Mail-Abgleich meldet `ErrorAccessDenied` | Administratorzustimmung für `Mail.Read` fehlt, oder die ApplicationAccessPolicy erlaubt das Postfach nicht |
|
||||||
|
| Berater bekommt keine Mail bei neuem Projekt | Adresse und Schalter unter *Einstellungen → Akquise*; Log `journalctl -u perinco-crm-backend \| grep 'Akquise] Mail'`; bei `ErrorAccessDenied` fehlt die Zustimmung für `Mail.Send` |
|
||||||
|
|
||||||
Nützliche Abfragen:
|
Nützliche Abfragen:
|
||||||
|
|
||||||
|
|||||||
@@ -983,6 +983,15 @@ wurden die vorhandenen Berater einmalig mit `vorname.nachname@perinco.de`
|
|||||||
vorbelegt - Umlaute ausgeschrieben, bei mehrteiligen Namen erster und letzter
|
vorbelegt - Umlaute ausgeschrieben, bei mehrteiligen Namen erster und letzter
|
||||||
Teil. Berater mit nur einem Namen blieben leer und brauchen eine Adresse von Hand.
|
Teil. Berater mit nur einem Namen blieben leer und brauchen eine Adresse von Hand.
|
||||||
|
|
||||||
|
Darunter steht der Haken **Mail bei neuem Projekt** (seit 21.10.2026, anfangs
|
||||||
|
bei allen an). Ist er gesetzt, bekommt der Berater eine Mail von
|
||||||
|
crm@perinco.de, sobald er in der Akquise einem Projekt zugeordnet wird - beim
|
||||||
|
Anlegen und auch, wenn er später beim Bearbeiten dazukommt. Wer schon
|
||||||
|
zugeordnet war, bekommt beim erneuten Speichern keine zweite Mail. Die Mail
|
||||||
|
enthält die Projektdaten und den Link zur Ausschreibung; eine Antwort darauf
|
||||||
|
geht an den, der das Projekt eingetragen hat. Ohne hinterlegte Adresse geht
|
||||||
|
nichts heraus, der Haken zeigt dann einen Hinweis.
|
||||||
|
|
||||||
Im Reiter **Scanner** steht das **Suchintervall (Minuten)** - also wie oft der
|
Im Reiter **Scanner** steht das **Suchintervall (Minuten)** - also wie oft der
|
||||||
Hintergrund-Scanner die Börsen durchsucht.
|
Hintergrund-Scanner die Börsen durchsucht.
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ Mal etwas:
|
|||||||
* `addConsultantEmail()` legt `crm_acquisition_options.email` an und belegt die
|
* `addConsultantEmail()` legt `crm_acquisition_options.email` an und belegt die
|
||||||
vorhandenen Berater einmalig mit `vorname.nachname@perinco.de` vor
|
vorhandenen Berater einmalig mit `vorname.nachname@perinco.de` vor
|
||||||
(`guessPerincoEmail()`, Merker `consultant_email_seeded`). Namen ohne
|
(`guessPerincoEmail()`, Merker `consultant_email_seeded`). Namen ohne
|
||||||
Nachnamen bleiben leer.
|
Nachnamen bleiben leer. Dieselbe Funktion legt `notify` (Vorgabe 1) an.
|
||||||
|
|
||||||
`ensureAdminUser()` legt nur an, wenn `crm_users` **leer** ist. Ohne
|
`ensureAdminUser()` legt nur an, wenn `crm_users` **leer** ist. Ohne
|
||||||
`ADMIN_PASSWORD` erzeugt es ein Zufallspasswort und schreibt es einmalig ins Log -
|
`ADMIN_PASSWORD` erzeugt es ein Zufallspasswort und schreibt es einmalig ins Log -
|
||||||
@@ -106,6 +106,7 @@ Alle unter `router.use(requireAuth)`.
|
|||||||
| `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /:id/acquisition`, `POST /:id/copy`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete. `/:id/acquisition` nimmt eine Übernahme zurück: alle lebenden Opportunities mit derselben `acquisition_id` in den Papierkorb, beim Eintrag `converted_at`/`opportunity_id` leeren (Funddatum bleibt), in einer Transaktion. `/restore` hängt den Eintrag wieder an die Opportunity, wenn er frei ist. `/:id/copy` (`{ consultants, stage }`) legt je Berater eine Kopie an - Projektdaten, Ansprechpartner, Zuständiger und `acquisition_id` kommen mit, Interaktion und nächster Schritt nicht; jeder Angemeldete |
|
| `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /:id/acquisition`, `POST /:id/copy`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete. `/:id/acquisition` nimmt eine Übernahme zurück: alle lebenden Opportunities mit derselben `acquisition_id` in den Papierkorb, beim Eintrag `converted_at`/`opportunity_id` leeren (Funddatum bleibt), in einer Transaktion. `/restore` hängt den Eintrag wieder an die Opportunity, wenn er frei ist. `/:id/copy` (`{ consultants, stage }`) legt je Berater eine Kopie an - Projektdaten, Ansprechpartner, Zuständiger und `acquisition_id` kommen mit, Interaktion und nächster Schritt nicht; 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 |
|
| `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. Für Administratoren zusätzlich `ms_secret`, sobald das Client-Secret in weniger als 30 Tagen abläuft |
|
| `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. Für Administratoren zusätzlich `ms_secret`, sobald das Client-Secret in weniger als 30 Tagen abläuft |
|
||||||
|
| `acquisitionNotify.js` (kein Router) | Berater-Mail bei neuem Projekt: `addedConsultants()` (wer ist neu), `buildMessage()` (Betreff + escaptes HTML), `notifyConsultants()` (Versand über Graph `sendMail` aus `MS_MAILBOX`, wirft nie). Aufgerufen von `POST`/`PATCH` in `acquisitions.js` nach der Antwort. Siehe [11 Postfach](11-postfach.md#berater-mail-bei-neuem-projekt) |
|
||||||
| `link.js` (kein Router) | `normalizeLink()` für den Link zur Ausschreibung: nur http/https, ohne Schema wird `https://` ergänzt, max. 1024 Zeichen. Geteilt von Akquise und Opportunities, damit ein übernommener Link nicht an einer anderen Prüfung scheitert |
|
| `link.js` (kein Router) | `normalizeLink()` für den Link zur Ausschreibung: nur http/https, ohne Schema wird `https://` ergänzt, max. 1024 Zeichen. Geteilt von Akquise und Opportunities, damit ein übernommener Link nicht an einer anderen Prüfung scheitert |
|
||||||
| `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IN_INBOX`, `IN_ARCHIVE`, `RETURNED`, `IS_OPEN` (nur noch für den Löschlauf), `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`) |
|
| `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IN_INBOX`, `IN_ARCHIVE`, `RETURNED`, `IS_OPEN` (nur noch für den Löschlauf), `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 |
|
| `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 |
|
||||||
|
|||||||
+40
-2
@@ -66,6 +66,9 @@ Login.
|
|||||||
3. **API-Berechtigung** Microsoft Graph → *Anwendungsberechtigungen* →
|
3. **API-Berechtigung** Microsoft Graph → *Anwendungsberechtigungen* →
|
||||||
`Mail.Read`, danach Administratorzustimmung erteilen.
|
`Mail.Read`, danach Administratorzustimmung erteilen.
|
||||||
`Mail.ReadBasic` genügt nicht, es liefert keinen Mailtext.
|
`Mail.ReadBasic` genügt nicht, es liefert keinen Mailtext.
|
||||||
|
Seit 21.10.2026 zusätzlich `Mail.Send` (erteilt) - damit verschickt das CRM
|
||||||
|
die [Berater-Mail bei neuem Projekt](#berater-mail-bei-neuem-projekt) aus
|
||||||
|
diesem Postfach.
|
||||||
4. **Zugriff auf dieses eine Postfach begrenzen.** Anwendungsberechtigungen
|
4. **Zugriff auf dieses eine Postfach begrenzen.** Anwendungsberechtigungen
|
||||||
gelten tenant-weit; ohne Richtlinie darf die App jedes Postfach lesen:
|
gelten tenant-weit; ohne Richtlinie darf die App jedes Postfach lesen:
|
||||||
|
|
||||||
@@ -386,10 +389,45 @@ Fremdschlüssel nicht.
|
|||||||
| HTML-Anzeige | ungefiltert | serverseitig entschärft, `sandbox=""`, externe Bilder gesperrt |
|
| HTML-Anzeige | ungefiltert | serverseitig entschärft, `sandbox=""`, externe Bilder gesperrt |
|
||||||
| Zeitangaben | gemischt | durchgehend UTC in der Datenbank, Umrechnung im Frontend |
|
| Zeitangaben | gemischt | durchgehend UTC in der Datenbank, Umrechnung im Frontend |
|
||||||
|
|
||||||
|
## Berater-Mail bei neuem Projekt
|
||||||
|
|
||||||
|
Seit 21.10.2026 verschickt das CRM selbst Mails - bisher nur eine Sorte: Wird in
|
||||||
|
der Akquise ein Projekt eingetragen und dabei ein Berater ausgewählt, bekommt
|
||||||
|
dieser eine Mail mit Titel, Endkunde, Anbieter, Tagessatz, Zeitraum, Link zur
|
||||||
|
Ausschreibung und Beschreibung (gekürzt auf 1500 Zeichen).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant B as Browser
|
||||||
|
participant API as POST/PATCH /api/acquisitions
|
||||||
|
participant N as acquisitionNotify.js
|
||||||
|
participant G as Graph sendMail
|
||||||
|
B->>API: Eintrag mit Berater speichern
|
||||||
|
API-->>B: 201 / 200 (sofort)
|
||||||
|
API->>N: notifyConsultants(eintrag, neue Berater, benutzer)
|
||||||
|
N->>N: Adresse + Schalter je Berater aus crm_acquisition_options
|
||||||
|
N->>G: POST /users/crm@perinco.de/sendMail
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Absender** ist `MS_MAILBOX` (crm@perinco.de), Kopie in *Gesendete
|
||||||
|
Elemente*. **Reply-To** ist der Benutzer, der den Eintrag gespeichert hat -
|
||||||
|
eine Antwort des Beraters landet also bei ihm, nicht im CRM-Postfach.
|
||||||
|
* **Wer** eine Mail bekommt: Beim Anlegen alle ausgewählten Berater, beim
|
||||||
|
Bearbeiten nur die neu hinzugekommenen. Voraussetzung je Berater: Adresse
|
||||||
|
hinterlegt und Schalter *Mail bei neuem Projekt* an (*Einstellungen →
|
||||||
|
Akquise*). Der Schalter war bei Einführung für alle an.
|
||||||
|
* **Fehler** brechen das Speichern nie ab. Der Versand läuft nach der Antwort an
|
||||||
|
den Browser; Erfolg und Fehler stehen im Log:
|
||||||
|
`journalctl -u perinco-crm-backend | grep 'Akquise] Mail'`.
|
||||||
|
* **Ohne M365-Anbindung** (Secret fehlt) wird nur eine Warnung geloggt.
|
||||||
|
* Die `ApplicationAccessPolicy` (siehe oben) gilt auch für `Mail.Send` - die App
|
||||||
|
darf also nur aus crm@perinco.de senden, nicht im Namen anderer.
|
||||||
|
|
||||||
## Was noch nicht drin ist
|
## Was noch nicht drin ist
|
||||||
|
|
||||||
* **Senden, Antworten, Weiterleiten.** Braucht `Mail.ReadWrite` und `Mail.Send`
|
* **Antworten, Weiterleiten aus dem Postfachreiter.** Senden geht seit der
|
||||||
und damit eine erneute Administratorzustimmung.
|
Berater-Mail (`Mail.Send`); Antworten auf eine konkrete Mail bräuchte
|
||||||
|
zusätzlich `Mail.ReadWrite`.
|
||||||
* **Anhänge.** `has_attachments` wird geführt und angezeigt, die Dateien
|
* **Anhänge.** `has_attachments` wird geführt und angezeigt, die Dateien
|
||||||
selbst werden nicht geholt.
|
selbst werden nicht geholt.
|
||||||
* **Automatische Zuordnung zu Opportunities.** Die Zuordnung erfolgt per Hand;
|
* **Automatische Zuordnung zu Opportunities.** Die Zuordnung erfolgt per Hand;
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ MS_CLIENT_ID=
|
|||||||
MS_CLIENT_SECRET=
|
MS_CLIENT_SECRET=
|
||||||
MS_MAILBOX=crm@perinco.de
|
MS_MAILBOX=crm@perinco.de
|
||||||
|
|
||||||
|
# Adresse des CRM fuer den Link in der Berater-Mail bei neuem Projekt
|
||||||
|
# (braucht zusaetzlich die Anwendungsberechtigung Mail.Send).
|
||||||
|
# CRM_PUBLIC_URL=https://crm.perinco.de
|
||||||
|
|
||||||
# Ordner im Postfach. "inbox" ist ein von Graph vorgegebener Name.
|
# Ordner im Postfach. "inbox" ist ein von Graph vorgegebener Name.
|
||||||
# MS_MAIL_FOLDER=inbox
|
# MS_MAIL_FOLDER=inbox
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
"dev": "node --watch src/server.js",
|
"dev": "node --watch src/server.js",
|
||||||
"migrate": "node src/migrate.js",
|
"migrate": "node src/migrate.js",
|
||||||
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs && node test/sessionUser.test.mjs && node test/sso.test.mjs && node test/msSecret.test.mjs && node test/ai.test.mjs"
|
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/acquisitionNotify.test.mjs &&node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs && node test/sessionUser.test.mjs && node test/sso.test.mjs && node test/msSecret.test.mjs && node test/ai.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { pool } from './db.js';
|
||||||
|
import { graphRequest, mailboxPath } from './mail/graph.js';
|
||||||
|
import { mailConfigured } from './mail/config.js';
|
||||||
|
import { splitConsultants } from './acquisitionRules.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mail an Berater, sobald sie in der Akquise einem Projekt zugeordnet werden.
|
||||||
|
*
|
||||||
|
* Wer eine Mail bekommt, steht in crm_acquisition_options: Adresse (`email`)
|
||||||
|
* und Schalter (`notify`), beides gepflegt unter Einstellungen -> Akquise.
|
||||||
|
* Ohne Adresse oder mit ausgeschaltetem Schalter geht nichts heraus.
|
||||||
|
*
|
||||||
|
* Versendet wird ueber Microsoft Graph aus dem CRM-Postfach (MS_MAILBOX) - die
|
||||||
|
* App hat dafuer die Anwendungsberechtigung Mail.Send. Antworten gehen per
|
||||||
|
* Reply-To an den Benutzer, der das Projekt eingetragen hat.
|
||||||
|
*
|
||||||
|
* Der Versand laeuft nach der Antwort an den Browser und darf das Speichern nie
|
||||||
|
* scheitern lassen: ein Fehler landet im Log, der Eintrag ist trotzdem da.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PUBLIC_URL = (process.env.CRM_PUBLIC_URL || 'https://crm.perinco.de').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
/** Beschreibung in der Mail kuerzen - der volle Text steht im CRM. */
|
||||||
|
const MAX_DESCRIPTION_CHARS = 1500;
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2026-10-21" -> "21.10.2026" */
|
||||||
|
function formatDate(value) {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value ?? ''));
|
||||||
|
return match ? `${match[3]}.${match[2]}.${match[1]}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEuro(value) {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
return `${Number(value).toLocaleString('de-DE', { maximumFractionDigits: 2 })} €`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Namen, die in `after` stehen, aber nicht in `before` - ohne Ruecksicht auf
|
||||||
|
* Gross- und Kleinschreibung. Beim Anlegen ist `before` leer.
|
||||||
|
*/
|
||||||
|
export function addedConsultants(before, after) {
|
||||||
|
const known = new Set(splitConsultants(before).map((name) => name.toLowerCase()));
|
||||||
|
return splitConsultants(after).filter((name) => !known.has(name.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Betreff und HTML-Text der Mail an einen Berater. */
|
||||||
|
export function buildMessage(acquisition, consultantName) {
|
||||||
|
const period = [formatDate(acquisition.start_date), formatDate(acquisition.end_date)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' bis ');
|
||||||
|
const rows = [
|
||||||
|
['Projekt', acquisition.title],
|
||||||
|
['Endkunde', acquisition.company],
|
||||||
|
['Anbieter', acquisition.provider],
|
||||||
|
['Kanal', acquisition.channel],
|
||||||
|
['Tagessatz', formatEuro(acquisition.daily_rate)],
|
||||||
|
['Projektpreis', formatEuro(acquisition.fixed_price)],
|
||||||
|
['Zeitraum', period || null],
|
||||||
|
['Laufzeit', acquisition.duration_days ? `${acquisition.duration_days} Tage` : null],
|
||||||
|
['Eingetragen von', acquisition.created_by_name],
|
||||||
|
].filter(([, value]) => value !== null && value !== undefined && value !== '');
|
||||||
|
|
||||||
|
const table = rows
|
||||||
|
.map(
|
||||||
|
([label, value]) =>
|
||||||
|
`<tr><td style="padding:4px 16px 4px 0;color:#666;vertical-align:top">${escapeHtml(label)}</td>` +
|
||||||
|
`<td style="padding:4px 0">${escapeHtml(value)}</td></tr>`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
let description = String(acquisition.description ?? '').trim();
|
||||||
|
if (description.length > MAX_DESCRIPTION_CHARS) {
|
||||||
|
description = `${description.slice(0, MAX_DESCRIPTION_CHARS).trimEnd()} …`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstName = String(consultantName).trim().split(/\s+/)[0];
|
||||||
|
const html = [
|
||||||
|
'<div style="font-family:Segoe UI,Arial,sans-serif;font-size:14px;color:#222">',
|
||||||
|
`<p>Hallo ${escapeHtml(firstName)},</p>`,
|
||||||
|
'<p>in der Akquise wurde ein neues Projekt eingetragen und dir zugeordnet:</p>',
|
||||||
|
`<table style="border-collapse:collapse;font-size:14px">${table}</table>`,
|
||||||
|
acquisition.link
|
||||||
|
? `<p><a href="${escapeHtml(acquisition.link)}">Zur Ausschreibung</a></p>`
|
||||||
|
: '',
|
||||||
|
description
|
||||||
|
? `<p style="white-space:pre-wrap;border-left:3px solid #ddd;padding-left:12px;color:#444">${escapeHtml(description)}</p>`
|
||||||
|
: '',
|
||||||
|
`<p><a href="${PUBLIC_URL}/">Im perinco CRM öffnen</a></p>`,
|
||||||
|
'<p style="color:#888;font-size:12px">Automatische Nachricht aus dem perinco CRM. ',
|
||||||
|
'Die Benachrichtigung lässt sich unter Einstellungen → Akquise je Berater abschalten.</p>',
|
||||||
|
'</div>',
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
return { subject: `Neues Projekt für dich: ${acquisition.title}`, html };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mails an die genannten Berater verschicken, soweit sie eine Adresse haben
|
||||||
|
* und der Schalter an ist. Wirft nicht - Fehler werden geloggt.
|
||||||
|
*/
|
||||||
|
export async function notifyConsultants(acquisition, names, actor) {
|
||||||
|
try {
|
||||||
|
if (!acquisition || names.length === 0) return;
|
||||||
|
if (!(await mailConfigured())) {
|
||||||
|
console.warn('[Akquise] Berater-Mail übersprungen: M365-Anbindung nicht eingerichtet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [options] = await pool.query(
|
||||||
|
`SELECT value, email FROM crm_acquisition_options
|
||||||
|
WHERE field = 'consultant' AND notify = 1 AND email IS NOT NULL AND email <> ''`
|
||||||
|
);
|
||||||
|
const byName = new Map(options.map((row) => [row.value.toLowerCase(), row]));
|
||||||
|
|
||||||
|
for (const name of names) {
|
||||||
|
const option = byName.get(name.toLowerCase());
|
||||||
|
if (!option) continue;
|
||||||
|
|
||||||
|
const { subject, html } = buildMessage(acquisition, option.value);
|
||||||
|
const message = {
|
||||||
|
subject,
|
||||||
|
body: { contentType: 'HTML', content: html },
|
||||||
|
toRecipients: [{ emailAddress: { address: option.email, name: option.value } }],
|
||||||
|
};
|
||||||
|
if (actor?.email) {
|
||||||
|
message.replyTo = [{ emailAddress: { address: actor.email, name: actor.name || actor.email } }];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await graphRequest(mailboxPath('/sendMail'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: { message, saveToSentItems: true },
|
||||||
|
});
|
||||||
|
console.log(`[Akquise] Mail an ${option.email} zu Eintrag ${acquisition.id} verschickt.`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[Akquise] Mail an ${option.email} zu Eintrag ${acquisition.id} fehlgeschlagen: ${err.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[Akquise] Berater-Mail fehlgeschlagen: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -299,6 +299,23 @@ async function addConsultantEmail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Schalter "Mail bei neuem Projekt" (siehe acquisitionNotify.js). Der
|
||||||
|
* Vorgabewert 1 schaltet ihn beim Anlegen der Spalte fuer alle vorhandenen
|
||||||
|
* Berater ein - so war es gewuenscht; abschalten geht je Berater.
|
||||||
|
*/
|
||||||
|
const [notifyColumn] = await pool.query(
|
||||||
|
`SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'crm_acquisition_options'
|
||||||
|
AND COLUMN_NAME = 'notify' LIMIT 1`
|
||||||
|
);
|
||||||
|
if (!notifyColumn[0]) {
|
||||||
|
await pool.query(
|
||||||
|
'ALTER TABLE crm_acquisition_options ADD COLUMN notify TINYINT(1) NOT NULL DEFAULT 1 AFTER email'
|
||||||
|
);
|
||||||
|
console.log('[Akquise] Berater-Mail bei neuem Projekt eingefuehrt - fuer alle Berater an.');
|
||||||
|
}
|
||||||
|
|
||||||
const flag = 'consultant_email_seeded';
|
const flag = 'consultant_email_seeded';
|
||||||
const [rows] = await pool.query('SELECT value FROM crm_settings WHERE key_name = ? LIMIT 1', [flag]);
|
const [rows] = await pool.query('SELECT value FROM crm_settings WHERE key_name = ? LIMIT 1', [flag]);
|
||||||
if (rows[0]?.value === 'true') return;
|
if (rows[0]?.value === 'true') return;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
splitConsultants,
|
splitConsultants,
|
||||||
} from '../acquisitionRules.js';
|
} from '../acquisitionRules.js';
|
||||||
import { normalizeLink } from '../link.js';
|
import { normalizeLink } from '../link.js';
|
||||||
|
import { addedConsultants, notifyConsultants } from '../acquisitionNotify.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
@@ -295,11 +296,16 @@ async function purgeOldEntries() {
|
|||||||
/** Alle Auswahllisten als { sender: [...], channel: [...], consultant: [...] }. */
|
/** Alle Auswahllisten als { sender: [...], channel: [...], consultant: [...] }. */
|
||||||
async function loadOptions() {
|
async function loadOptions() {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
'SELECT id, field, value, email FROM crm_acquisition_options ORDER BY field, value'
|
'SELECT id, field, value, email, notify FROM crm_acquisition_options ORDER BY field, value'
|
||||||
);
|
);
|
||||||
const options = { sender: [], channel: [], consultant: [] };
|
const options = { sender: [], channel: [], consultant: [] };
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
options[row.field].push({ id: row.id, value: row.value, email: row.email ?? null });
|
options[row.field].push({
|
||||||
|
id: row.id,
|
||||||
|
value: row.value,
|
||||||
|
email: row.email ?? null,
|
||||||
|
notify: Boolean(row.notify),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
@@ -477,7 +483,7 @@ router.post('/options', async (req, res, next) => {
|
|||||||
'INSERT INTO crm_acquisition_options (field, value, email, created_by) VALUES (?, ?, ?, ?)',
|
'INSERT INTO crm_acquisition_options (field, value, email, created_by) VALUES (?, ?, ?, ?)',
|
||||||
[field, value, email.value, req.user.id]
|
[field, value, email.value, req.user.id]
|
||||||
);
|
);
|
||||||
res.status(201).json({ id: result.insertId, field, value, email: email.value });
|
res.status(201).json({ id: result.insertId, field, value, email: email.value, notify: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.code === 'ER_DUP_ENTRY') {
|
if (err?.code === 'ER_DUP_ENTRY') {
|
||||||
return res.status(409).json({ error: 'Dieser Wert steht schon in der Liste.' });
|
return res.status(409).json({ error: 'Dieser Wert steht schon in der Liste.' });
|
||||||
@@ -498,47 +504,61 @@ router.patch('/options/:id', async (req, res, next) => {
|
|||||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Options-ID.' });
|
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Options-ID.' });
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* `value` und `email` sind beide optional: die Einstellungen aendern mal den
|
* `value`, `email` und `notify` sind alle optional: die Einstellungen aendern
|
||||||
* Namen, mal nur die Adresse. Fehlt `value`, bleibt der Name, wie er ist.
|
* mal den Namen, mal nur die Adresse oder den Mail-Schalter. Was fehlt, bleibt.
|
||||||
*/
|
*/
|
||||||
const emailGiven = req.body?.email !== undefined;
|
const emailGiven = req.body?.email !== undefined;
|
||||||
const email = emailGiven ? normalizeOptionEmail(req.body.email) : null;
|
const email = emailGiven ? normalizeOptionEmail(req.body.email) : null;
|
||||||
if (email?.error) return res.status(400).json({ error: email.error });
|
if (email?.error) return res.status(400).json({ error: email.error });
|
||||||
|
|
||||||
|
const notifyGiven = req.body?.notify !== undefined;
|
||||||
|
if (notifyGiven && typeof req.body.notify !== 'boolean') {
|
||||||
|
return res.status(400).json({ error: 'Der Mail-Schalter muss an oder aus sein.' });
|
||||||
|
}
|
||||||
|
|
||||||
const valueGiven = req.body?.value !== undefined;
|
const valueGiven = req.body?.value !== undefined;
|
||||||
const requested = valueGiven ? emptyToNull(req.body.value) : null;
|
const requested = valueGiven ? emptyToNull(req.body.value) : null;
|
||||||
if (valueGiven && !requested) return res.status(400).json({ error: 'Ein Wert ist erforderlich.' });
|
if (valueGiven && !requested) return res.status(400).json({ error: 'Ein Wert ist erforderlich.' });
|
||||||
if (!valueGiven && !emailGiven) return res.status(400).json({ error: 'Nichts zu ändern.' });
|
if (!valueGiven && !emailGiven && !notifyGiven) {
|
||||||
|
return res.status(400).json({ error: 'Nichts zu ändern.' });
|
||||||
|
}
|
||||||
|
|
||||||
const conn = await pool.getConnection();
|
const conn = await pool.getConnection();
|
||||||
try {
|
try {
|
||||||
const [rows] = await conn.query(
|
const [rows] = await conn.query(
|
||||||
'SELECT field, value, email FROM crm_acquisition_options WHERE id = ? LIMIT 1',
|
'SELECT field, value, email, notify FROM crm_acquisition_options WHERE id = ? LIMIT 1',
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
const existing = rows[0];
|
const existing = rows[0];
|
||||||
if (!existing) return res.status(404).json({ error: 'Eintrag der Liste nicht gefunden.' });
|
if (!existing) return res.status(404).json({ error: 'Eintrag der Liste nicht gefunden.' });
|
||||||
if (emailGiven && existing.field !== 'consultant') {
|
if ((emailGiven || notifyGiven) && existing.field !== 'consultant') {
|
||||||
return res.status(400).json({ error: 'Eine E-Mail-Adresse gibt es nur bei Beratern.' });
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'E-Mail-Adresse und Benachrichtigung gibt es nur bei Beratern.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = requested ?? existing.value;
|
const value = requested ?? existing.value;
|
||||||
const newEmail = emailGiven ? email.value : existing.email ?? null;
|
const newEmail = emailGiven ? email.value : existing.email ?? null;
|
||||||
|
const newNotify = notifyGiven ? req.body.notify : Boolean(existing.notify);
|
||||||
const limit = existing.field === 'channel' ? 60 : 190;
|
const limit = existing.field === 'channel' ? 60 : 190;
|
||||||
if (value.length > limit) {
|
if (value.length > limit) {
|
||||||
return res.status(400).json({ error: `Der Wert ist zu lang (max. ${limit} Zeichen).` });
|
return res.status(400).json({ error: `Der Wert ist zu lang (max. ${limit} Zeichen).` });
|
||||||
}
|
}
|
||||||
|
const result = { id, field: existing.field, value, email: newEmail, notify: newNotify };
|
||||||
if (value === existing.value) {
|
if (value === existing.value) {
|
||||||
await conn.query('UPDATE crm_acquisition_options SET email = ? WHERE id = ?', [newEmail, id]);
|
await conn.query('UPDATE crm_acquisition_options SET email = ?, notify = ? WHERE id = ?', [
|
||||||
return res.json({ id, field: existing.field, value, email: newEmail, updated: 0 });
|
newEmail,
|
||||||
|
newNotify ? 1 : 0,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
return res.json({ ...result, updated: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await conn.beginTransaction();
|
await conn.beginTransaction();
|
||||||
await conn.query('UPDATE crm_acquisition_options SET value = ?, email = ? WHERE id = ?', [
|
await conn.query(
|
||||||
value,
|
'UPDATE crm_acquisition_options SET value = ?, email = ?, notify = ? WHERE id = ?',
|
||||||
newEmail,
|
[value, newEmail, newNotify ? 1 : 0, id]
|
||||||
id,
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
let touched;
|
let touched;
|
||||||
if (existing.field === 'consultant') {
|
if (existing.field === 'consultant') {
|
||||||
@@ -569,7 +589,7 @@ router.patch('/options/:id', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
await conn.commit();
|
await conn.commit();
|
||||||
|
|
||||||
res.json({ id, field: existing.field, value, email: newEmail, updated: touched });
|
res.json({ ...result, updated: touched });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await conn.rollback().catch(() => {});
|
await conn.rollback().catch(() => {});
|
||||||
if (err?.code === 'ER_DUP_ENTRY') {
|
if (err?.code === 'ER_DUP_ENTRY') {
|
||||||
@@ -733,7 +753,10 @@ router.post('/', async (req, res, next) => {
|
|||||||
VALUES (${keys.map(() => '?').join(', ')})`,
|
VALUES (${keys.map(() => '?').join(', ')})`,
|
||||||
keys.map((key) => columns[key])
|
keys.map((key) => columns[key])
|
||||||
);
|
);
|
||||||
res.status(201).json(await fetchAcquisition(result.insertId));
|
const created = await fetchAcquisition(result.insertId);
|
||||||
|
res.status(201).json(created);
|
||||||
|
// Nach der Antwort: der Versand soll das Speichern weder bremsen noch kippen.
|
||||||
|
notifyConsultants(created, addedConsultants(null, created?.consultant), req.user);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@@ -793,7 +816,12 @@ router.patch('/:id', async (req, res, next) => {
|
|||||||
`UPDATE crm_acquisitions SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`,
|
`UPDATE crm_acquisitions SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`,
|
||||||
[...keys.map((key) => columns[key]), id]
|
[...keys.map((key) => columns[key]), id]
|
||||||
);
|
);
|
||||||
res.json(await fetchAcquisition(id));
|
const updated = await fetchAcquisition(id);
|
||||||
|
res.json(updated);
|
||||||
|
// Nur wer neu dazugekommen ist, bekommt eine Mail - die anderen kennen das Projekt.
|
||||||
|
if ('consultant' in columns) {
|
||||||
|
notifyConsultants(updated, addedConsultants(existing.consultant, updated?.consultant), req.user);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -576,6 +576,7 @@ CREATE TABLE IF NOT EXISTS crm_acquisition_options (
|
|||||||
field ENUM('sender','channel','consultant') NOT NULL,
|
field ENUM('sender','channel','consultant') NOT NULL,
|
||||||
value VARCHAR(190) NOT NULL,
|
value VARCHAR(190) NOT NULL,
|
||||||
email VARCHAR(190) NULL,
|
email VARCHAR(190) NULL,
|
||||||
|
notify TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
created_by INT UNSIGNED NULL,
|
created_by INT UNSIGNED NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* Pruefungen der Berater-Mail (acquisitionNotify.js): laeuft mit `npm test`.
|
||||||
|
*
|
||||||
|
* Geprueft wird, wer eine Mail bekommt (nur neu hinzugekommene Berater) und
|
||||||
|
* dass der Mailtext Eingaben escaped - Titel und Beschreibung kommen aus
|
||||||
|
* Freitextfeldern und landen als HTML im Postfach des Beraters.
|
||||||
|
*/
|
||||||
|
const { addedConsultants, buildMessage } = await import('../src/acquisitionNotify.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('\nWer bekommt eine Mail');
|
||||||
|
check('Anlegen: alle', same(addedConsultants(null, 'Nico, Lisa'), ['Nico', 'Lisa']));
|
||||||
|
check('Ohne Berater: niemand', same(addedConsultants(null, null), []));
|
||||||
|
check('Bearbeiten: nur neue', same(addedConsultants('Nico', 'Nico, Lisa'), ['Lisa']));
|
||||||
|
check('Schreibweise egal', same(addedConsultants('nico', 'Nico'), []));
|
||||||
|
check('Entfernen: niemand', same(addedConsultants('Nico, Lisa', 'Nico'), []));
|
||||||
|
|
||||||
|
console.log('\nMailtext');
|
||||||
|
const message = buildMessage(
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'SAP <script>',
|
||||||
|
company: 'ACME & Co',
|
||||||
|
provider: 'Hays',
|
||||||
|
daily_rate: 950,
|
||||||
|
start_date: '2026-11-01',
|
||||||
|
end_date: '2026-12-31',
|
||||||
|
link: 'https://example.com/p?a=1&b=2',
|
||||||
|
description: 'Zeile 1\nZeile 2',
|
||||||
|
created_by_name: 'Sascha Wolf',
|
||||||
|
},
|
||||||
|
'Jörg Müller'
|
||||||
|
);
|
||||||
|
check('Betreff mit Titel', message.subject === 'Neues Projekt für dich: SAP <script>');
|
||||||
|
check('Titel escaped', message.html.includes('SAP <script>') && !message.html.includes('<script>'));
|
||||||
|
check('Firma escaped', message.html.includes('ACME & Co'));
|
||||||
|
check('Anrede mit Vorname', message.html.includes('Hallo Jörg,'));
|
||||||
|
check('Datum deutsch', message.html.includes('01.11.2026 bis 31.12.2026'));
|
||||||
|
check('Link enthalten', message.html.includes('href="https://example.com/p?a=1&b=2"'));
|
||||||
|
check('Leere Felder fehlen', !message.html.includes('Projektpreis'));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
failures === 0 ? '\nAlle Pruefungen bestanden.' : `\n${failures} Pruefung(en) fehlgeschlagen.`
|
||||||
|
);
|
||||||
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
@@ -84,13 +84,53 @@ const EmailField: React.FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Schalter "Mail bei neuem Projekt". Ohne Adresse bleibt er bedienbar, sagt
|
||||||
|
* aber, dass so keine Mail herausgeht - sonst wirkt er an und tut nichts.
|
||||||
|
*/
|
||||||
|
const NotifyToggle: React.FC<{
|
||||||
|
option: AcquisitionOption;
|
||||||
|
onChange: (notify: boolean) => Promise<void>;
|
||||||
|
}> = ({ option, onChange }) => {
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const toggle = async (notify: boolean) => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await onChange(notify);
|
||||||
|
} catch {
|
||||||
|
// Meldung kommt aus onChange, der Haken bleibt auf dem gespeicherten Stand.
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="mt-1.5 flex cursor-pointer items-center gap-2 text-xs text-ink-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={option.notify}
|
||||||
|
disabled={isSaving}
|
||||||
|
onChange={(event) => toggle(event.target.checked)}
|
||||||
|
className="h-4 w-4 accent-brand-500"
|
||||||
|
/>
|
||||||
|
Mail bei neuem Projekt
|
||||||
|
{option.notify && !option.email && (
|
||||||
|
<span className="text-amber-600">- ohne Adresse geht keine Mail heraus</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const OptionRow: React.FC<{
|
const OptionRow: React.FC<{
|
||||||
option: AcquisitionOption;
|
option: AcquisitionOption;
|
||||||
onRenamed: (value: string) => Promise<void>;
|
onRenamed: (value: string) => Promise<void>;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
/** Nur bei "Berater" gesetzt - dann zeigt die Zeile das Adressfeld. */
|
/** Nur bei "Berater" gesetzt - dann zeigt die Zeile das Adressfeld. */
|
||||||
onEmailSaved?: (email: string) => Promise<void>;
|
onEmailSaved?: (email: string) => Promise<void>;
|
||||||
}> = ({ option, onRenamed, onDelete, onEmailSaved }) => {
|
/** Nur bei "Berater" gesetzt - Schalter fuer die Mail bei neuem Projekt. */
|
||||||
|
onNotifyChanged?: (notify: boolean) => Promise<void>;
|
||||||
|
}> = ({ option, onRenamed, onDelete, onEmailSaved, onNotifyChanged }) => {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(option.value);
|
const [draft, setDraft] = useState(option.value);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -125,6 +165,7 @@ const OptionRow: React.FC<{
|
|||||||
<EmailField option={option} onSave={onEmailSaved} />
|
<EmailField option={option} onSave={onEmailSaved} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{onNotifyChanged && <NotifyToggle option={option} onChange={onNotifyChanged} />}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -262,6 +303,22 @@ export const AcquisitionSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveNotify = async (option: AcquisitionOption, notify: boolean) => {
|
||||||
|
try {
|
||||||
|
await acquisitionOptionsApi.updateNotify(option.id, notify);
|
||||||
|
addToast(
|
||||||
|
notify
|
||||||
|
? `${option.value} bekommt eine Mail bei neuen Projekten.`
|
||||||
|
: `Keine Mails mehr an ${option.value}.`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
await load(true);
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
if (!toDelete) return;
|
if (!toDelete) return;
|
||||||
try {
|
try {
|
||||||
@@ -364,6 +421,9 @@ export const AcquisitionSettings: React.FC = () => {
|
|||||||
onEmailSaved={
|
onEmailSaved={
|
||||||
field === 'consultant' ? (email) => saveEmail(option, email) : undefined
|
field === 'consultant' ? (email) => saveEmail(option, email) : undefined
|
||||||
}
|
}
|
||||||
|
onNotifyChanged={
|
||||||
|
field === 'consultant' ? (notify) => saveNotify(option, notify) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -510,6 +510,13 @@ export const acquisitionOptionsApi = {
|
|||||||
{ method: 'PATCH', body: JSON.stringify({ email }) }
|
{ method: 'PATCH', body: JSON.stringify({ email }) }
|
||||||
),
|
),
|
||||||
|
|
||||||
|
/** Mail an den Berater bei neuem Projekt an- oder abschalten. */
|
||||||
|
updateNotify: (id: number, notify: boolean) =>
|
||||||
|
request<AcquisitionOption & { field: AcquisitionOptionField; updated: number }>(
|
||||||
|
`/acquisitions/options/${id}`,
|
||||||
|
{ method: 'PATCH', body: JSON.stringify({ notify }) }
|
||||||
|
),
|
||||||
|
|
||||||
/** Entfernt nur die Auswahl; `still_in_use` zaehlt die Eintraege, die den Wert behalten. */
|
/** Entfernt nur die Auswahl; `still_in_use` zaehlt die Eintraege, die den Wert behalten. */
|
||||||
remove: (id: number) =>
|
remove: (id: number) =>
|
||||||
request<{ ok: true; still_in_use: number }>(`/acquisitions/options/${id}`, { method: 'DELETE' }),
|
request<{ ok: true; still_in_use: number }>(`/acquisitions/options/${id}`, { method: 'DELETE' }),
|
||||||
|
|||||||
@@ -399,6 +399,8 @@ export interface AcquisitionOption {
|
|||||||
value: string;
|
value: string;
|
||||||
/** Nur bei "Berater" gepflegt, sonst immer null. */
|
/** Nur bei "Berater" gepflegt, sonst immer null. */
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
/** Nur bei "Berater": Mail, sobald er in der Akquise einem Projekt zugeordnet wird. */
|
||||||
|
notify: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
Reference in New Issue
Block a user