feat(akquise): gefundene Projekte als Eingang vor den Opportunities

Neuer Menuepunkt ueber den Opportunities, mit dem Unterpunkt "Archiv". Die
Liste traegt genau die Spalten, die beim Durchgehen zaehlen: Datum, Von,
Anbieter, Kanal, Projekttitel, Berater, ein Knopf fuer die Uebernahme und die
Symbole fuer Bearbeiten und Loeschen.

Eingabe steht ueber der Liste und ist bewusst kein Dialog - Projekte kommen in
Serie herein. Nach dem Eintragen bleiben Datum, Anbieter, Kanal und Berater
stehen, damit drei Projekte aus einer Mail nicht dreimal dieselben Angaben
kosten. Von, Anbieter und Berater schlagen frueher benutzte Werte vor. Was in
der Liste keine Spalte hat, aber in der Opportunity vorkommt (Endkunde,
Beschreibung, Tagessatz, Laufzeit, Projektpreis, Zeitraum, naechster Schritt,
Notizen), klappt darunter auf.

Eingang, Archiv und Loeschung ergeben sich aus dem Funddatum - 14 bzw. 90 Tage -
und aus converted_at. Es gibt keine Zustandsspalte, die ein Job pflegen muesste
und die von der Wirklichkeit abweichen kann. Der Loeschlauf raeumt beim Abruf
der Liste, hoechstens einmal pro Stunde; die Abfragen filtern Faelliges ohnehin
heraus.

Die Uebernahme legt Opportunity und Merker in einer Transaktion an. Anbieter,
Von, Kanal, Berater und Link haben in der Opportunity kein Feld und wandern als
Herkunftsvermerk in die Notizen.

Der Titel wird nur dann zum Link, wenn eine http(s)-Adresse hinterlegt ist:
"hays.de/projekt/1" ergaenzt das Backend zu https://, und die Anzeige laesst
nichts anderes durch als http und https.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-18 11:56:58 +02:00
co-authored by Claude Opus 5
parent 8741a9ae0c
commit b8a78f609c
16 changed files with 1833 additions and 7 deletions
+2 -2
View File
@@ -90,8 +90,8 @@ sofort draußen, ohne auf den Ablauf des Tokens zu warten.
```mermaid
flowchart TD
L["Anmeldung"] --> R{"Rolle"}
R -->|admin| A["Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"]
R -->|user| U["Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Mein Konto, Whisper"]
R -->|admin| A["Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"]
R -->|user| U["Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Mein Konto, Whisper"]
```
Bei den Mails darf jede Rolle lesen und zuordnen - `crm@perinco.de` ist ein
+21 -1
View File
@@ -23,6 +23,8 @@ erDiagram
crm_leads ||--o{ crm_lead_connections : from_lead_id
crm_leads ||--o{ crm_opportunity_contacts : ist
crm_opportunities ||--o{ crm_opportunity_contacts : hat
crm_users ||--o{ crm_acquisitions : created_by
crm_acquisitions |o--o| crm_opportunities : "wird zu"
crm_users {
int id PK
@@ -74,6 +76,18 @@ erDiagram
int lead_id PK,FK
varchar role "z.B. Entscheider"
}
crm_acquisitions {
int id PK
date found_at "entscheidet Eingang, Archiv, Loeschung"
varchar sender "Von"
varchar provider "Anbieter"
varchar channel "Mail | Telefon | Portal | ..."
varchar title "Projekttitel"
varchar consultant "Berater"
varchar link "macht den Titel klickbar"
datetime converted_at "uebernommen -> raus aus der Liste"
int opportunity_id FK
}
crm_events {
int id PK
varchar title
@@ -89,7 +103,7 @@ erDiagram
}
```
Vier Eigenheiten, die man kennen muss:
Fünf 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
@@ -106,6 +120,12 @@ Vier 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.
- **`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
gerechnete Grenze nicht. `converted_at` ist dabei die Wahrheit, nicht
`opportunity_id`: wird die Opportunity gelöscht, fällt der Verweis auf NULL,
der Eintrag soll aber nicht wieder im Eingang auftauchen.
### Postfach crm@perinco.de
+4
View File
@@ -9,6 +9,9 @@ sitzt und keine tiefen Links braucht.
```mermaid
flowchart TD
SB["Seitenleiste"] --> K["Kategorien"]
K --> AQ["Akquise"]
AQ --> AQ1["Eingang<br/>Funde der letzten 14 Tage"]
AQ --> AQ2["Archiv<br/>älter; nach 90 Tagen gelöscht"]
K --> O["Opportunities<br/>(Startansicht)"]
K --> C["Interviews"]
K --> L["Kontakte"]
@@ -61,6 +64,7 @@ flowchart TB
APP --> NAV
APP --> DATA
DATA --> V1["Opportunities"]
NOTE["Akquise lädt selbst<br/>acquisitionsApi, nicht über loadData()"]
DATA --> V2["Interviews"]
DATA --> V3["Kontakte<br/>mail_pool = false"]
DATA --> V5["Aus Mails<br/>mail_pool = true + Kontakte zum Abgleich"]
+78 -1
View File
@@ -9,6 +9,7 @@ Ein CRM für die Projektvermittlung. Es beantwortet vier Fragen:
| Frage | Bereich |
| --- | --- |
| Welche Projekte haben wir gefunden? | **Akquise** |
| Welche Geschäfte sind in Arbeit und was sind sie wert? | **Opportunities** |
| Wer ist wann wo, und wer ist beteiligt? | **Interviews** |
| Wen kennen wir, und wer kennt wen? | **Kontakte** |
@@ -31,6 +32,9 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
```
┌──────────────┬───────────────────────────────────────────┐
│ Kategorien │ Titel der Ansicht [Hell/Dunkel] │
│ Akquise ▾ │ │
│ Eingang │ │
│ Archiv │ │
│ Opportunities├──────────────────────────────────────────┤
│ Interviews │ │
│ Kontakte ▾ │ Inhalt │
@@ -69,9 +73,82 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
> Alle Abbildungen in dieser Anleitung zeigen die Anwendung mit **erfundenen
> Beispieldaten**. Firmen, Personen und Zahlen darin gibt es nicht.
## Akquise
Der Eingang für gefundene Projekte - alles, was per Mail, Telefon oder über ein
Portal hereinkommt, bevor daraus ein Geschäft wird. Zwei Unterpunkte: **Eingang**
mit den Funden der letzten 14 Tage und **Archiv** mit allem Älteren.
### Eintragen
Über der Liste steht das Formular. Absichtlich kein Fenster, das nach jedem
Eintrag zugeht: kommen drei Projekte in einer Mail, tippt man sie hintereinander
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 |
| **Projekttitel** | das einzige Pflichtfeld |
| **Berater** | 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.
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
und -ende, Tagessatz, Laufzeit, Projektpreis, Projektbeschreibung, nächster
Schritt und interne Notizen. Alles freiwillig und jederzeit nachtragbar; das
Volumen wird schon hier mitgerechnet.
Nach *Eintragen* bleiben **Datum, Anbieter, Kanal und Berater stehen**, der Rest
wird geleert - für das nächste Projekt aus derselben Quelle.
### Die Liste
| Spalte | Bedeutung |
| --- | --- |
| **Datum** | Funddatum; der Mauszeiger verrät das Alter in Tagen |
| **Von**, **Anbieter**, **Kanal** | woher es kam |
| **Projekttitel** | mit Link anklickbar (neuer Tab); darunter klein Endkunde, Tagessatz bzw. Festpreis und Personentage |
| **Berater** | für wen |
| **Opportunity** | ein Klick macht daraus eine Opportunity in der Phase *Neu* |
| Stift / Papierkorb | bearbeiten (füllt das Formular oben) und löschen |
Die Spaltenköpfe sortieren, die Suche geht über Titel, Anbieter, Berater,
Endkunde, Beschreibung und Notizen.
### Was von selbst passiert
* Nach **14 Tagen** rutscht ein Eintrag ins **Archiv**. Dort lässt er sich weiter
bearbeiten und jederzeit übernehmen.
* Nach **90 Tagen** wird er **gelöscht**. Wer ein Projekt behalten will,
übernimmt es als Opportunity.
* Wird ein Eintrag **übernommen**, verschwindet er aus Eingang und Archiv - er
lebt als Opportunity weiter.
Verschoben wird dabei nichts: Eingang und Archiv sind dieselbe Liste, nur nach
dem Funddatum geteilt. Es gibt also keinen Zustand, der klemmen kann.
### Aus dem Eintrag wird eine Opportunity
Der Knopf **Opportunity** legt sie sofort an: Phase *Neu*, 10 % Wahrscheinlichkeit,
zuständig ist der angemeldete Benutzer. Mit übernommen werden Titel, Endkunde,
Beschreibung, Tagessatz, Laufzeit, Projektpreis, Zeitraum und nächster Schritt.
Anbieter, Von, Kanal, Berater und Link haben in der Opportunity kein Feld - sie
landen als Herkunftsvermerk in den internen Notizen, damit nachvollziehbar
bleibt, woher das Projekt kam.
Ansprechpartner hängt man danach in der Opportunity dran; dort geht das auch für
Kontakte, die es noch nicht gibt.
## Opportunities
Der Startbereich: eine Opportunity ist ein Geschäft in Arbeit. Oben rechts
Eine Opportunity ist ein Geschäft in Arbeit. Oben rechts
wechselt man die Darstellung:
| Ansicht | wofür |
+6 -2
View File
@@ -83,6 +83,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 |
| `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:
@@ -255,6 +256,9 @@ Warum genau diese Konstanten festgelegt sind, steht in
| Datei | Inhalt |
| --- | --- |
| `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/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 |
@@ -275,11 +279,11 @@ nicht auseinanderlaufen:
| Export | Zweck |
| --- | --- |
| `ViewState`, `SettingsTab`, `ADMIN_SETTINGS_TABS`, `SCANNER_VIEWS` | Ansichten und Reiter |
| `ViewState`, `SettingsTab`, `ADMIN_SETTINGS_TABS`, `SCANNER_VIEWS`, `MAIL_VIEWS`, `ACQUISITION_VIEWS`, `CONTACT_VIEWS` | Ansichten, Reiter und aufklappbare Gruppen |
| `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`, `CONNECTION_TYPES` | die Aufzählungen der Datenbank |
| `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_KINDS`, `ACQUISITION_CHANNELS`, `CONNECTION_TYPES` | die Aufzählungen der Datenbank |
| `eventColor()`, `leadFullName()`, `leadInitials()`, `contactFullName()` | Anzeigelogik |
## Datenfluss im Frontend
+1
View File
@@ -21,6 +21,7 @@ Quelltext.
| **Kontakt-Vorschlag** | Aus einer Mail gelesene Person, die noch kein Kontakt ist. Tabelle `crm_mail_contact_candidates`. Wird übernommen, ergänzt oder verworfen. |
| **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. |
| **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. |