feat(akquise): Uebernommene Eintraege bleiben in der Liste

Bisher verschwand ein Fund mit der Uebernahme aus Eingang und Archiv.
Beim Durchgehen blieb damit offen, ob er schon angefasst wurde. Jetzt
bleibt der Eintrag stehen und zeigt statt "Opportunity" den blauen Knopf
"Uebernommen" mit Haken; ein Klick springt zur Opportunity.

Dafuer entscheidet nicht mehr IS_OPEN ueber die Listen, sondern die neuen
Regeln IN_INBOX und RETURNED: ein Rueckfaeller (Opportunity im Papierkorb
oder geloescht) gehoert weiter ins Archiv, alles andere bleibt in seiner
Liste. Zaehler und Menuezahl folgen derselben Regel. IS_OPEN bleibt fuer
den Loeschlauf: was zu einer lebenden Opportunity gehoert, wird nicht
weggeraeumt.

Solange die Opportunity lebt, sind Bearbeiten, Loeschen und "Zurueck in
die Akquise" am Eintrag gesperrt - er ist ihr Herkunftsnachweis, und der
Rueckweg von der Opportunity braucht ihn. Das Loeschen weist auch das
Backend ab, nicht nur die Oberflaeche.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-21 15:46:26 +02:00
co-authored by Claude Opus 5
parent 86aa7468af
commit 3e76a73f2e
8 changed files with 154 additions and 61 deletions
+8 -5
View File
@@ -190,11 +190,14 @@ Acht Eigenheiten, die man kennen muss:
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 - im **Archiv**
schon. Ob eine übernommene Bewerbung dort wieder erscheint, wird aus dem
Zustand der Opportunity abgeleitet (Papierkorb oder weg), nicht gespeichert:
wird sie zurückgeholt, verschwindet der Eintrag von selbst wieder. Die Regeln
dafür stehen in `server/src/acquisitionRules.js`.
der Eintrag gehört dann aber ins **Archiv** und nicht in den **Eingang**.
Übernommene Einträge bleiben in ihrer Liste stehen und sind dort als
*Übernommen* gekennzeichnet; erst als **Rückfaller** (`RETURNED`: übernommen
und die Opportunity im Papierkorb oder weg) wechseln sie ins Archiv,
unabhängig vom Alter. Das wird aus dem Zustand der Opportunity abgeleitet und
nicht gespeichert: wird sie zurückgeholt, gilt der Eintrag von selbst wieder
als übernommen. Die Regeln dafür stehen in
`server/src/acquisitionRules.js`.
Aus derselben Logik folgt, was **Zurück in den Eingang** (`POST /:id/reopen`) tut:
Es setzt `found_at` auf heute und leert `converted_at` samt `opportunity_id` -
ohne das Zurücksetzen von `converted_at` bliebe ein Rückfaller im Archiv
+22 -15
View File
@@ -280,15 +280,18 @@ Endkunde, Beschreibung und Notizen.
vorher nur die Zahl für das Dashboard (Tag, Berater, Anzahl), die
Projektdaten sind dann weg. Wer ein Projekt behalten will, übernimmt es als
Opportunity.
* Wird ein Eintrag **übernommen**, verschwindet er aus Eingang und Archiv - er
lebt als Opportunity weiter.
* Wird diese **Opportunity gelöscht**, taucht die Bewerbung wieder im **Archiv**
auf, gekennzeichnet mit *Opportunity im Papierkorb* bzw. *Opportunity
gelöscht*. Ohne das wäre sie nirgends mehr zu finden: aus der Akquise ist sie
mit der Übernahme heraus, und die Opportunity gibt es nicht mehr. Sie lässt
sich dann wieder bearbeiten und erneut übernehmen. Holt jemand die Opportunity
aus ihrem Papierkorb zurück, verschwindet der Eintrag von selbst wieder - und
auch ein solcher Rückfaller wird 90 Tage nach seinem Funddatum gelöscht.
* Wird ein Eintrag **übernommen**, **bleibt er in der Liste stehen**. Der Knopf
wird blau und heißt **Übernommen ✓**; ein Klick darauf springt zur
Opportunity. Bearbeiten und Löschen sind dann gesperrt - gepflegt wird in der
Opportunity, und der Eintrag bleibt als Herkunftsnachweis erhalten. Nach den
üblichen 14 Tagen rutscht er ins Archiv wie jeder andere Eintrag.
* Wird diese **Opportunity gelöscht**, wandert die Bewerbung ins **Archiv** -
unabhängig von ihrem Alter -, gekennzeichnet mit *Opportunity im Papierkorb*
bzw. *Opportunity gelöscht*. Sie zeigt dort wieder den grünen Knopf
*Opportunity* und lässt sich bearbeiten und erneut übernehmen. Holt jemand die
Opportunity aus ihrem Papierkorb zurück, gilt der Eintrag von selbst wieder als
übernommen - und auch ein solcher Rückfaller wird 90 Tage nach seinem
Funddatum gelöscht.
Verschoben wird dabei nichts: Eingang und Archiv sind dieselbe Liste, nur nach
dem Funddatum geteilt. Es gibt also keinen Zustand, der klemmen kann.
@@ -309,11 +312,12 @@ Bei einem **Rückfaller** (die Bewerbung war schon eine Opportunity) fragt das C
nach: mit dem Wiederherstellen fällt der Verweis auf die gelöschte Opportunity
weg. Der Eintrag ist danach wieder ein gewöhnlicher Fund. Liegt die Opportunity
noch im Papierkorb und wird sie zurückgeholt, hängt der Eintrag wieder an ihr und
verschwindet aus der Akquise - dasselbe Projekt steht nie an zwei Stellen.
ist in der Akquise wieder als *Übernommen* gekennzeichnet - dasselbe Projekt
wird nie zweimal bearbeitet.
Ein Eintrag, dessen Opportunity noch lebt, lässt sich hier nicht zurückholen - er
steht ohnehin in keiner der beiden Listen. Dafür gibt es den Weg von der
Opportunity aus (nächster Abschnitt).
Bei einem Eintrag, dessen Opportunity noch lebt, fehlt der Pfeil: er ist
übernommen und gehört zu ihr. Dafür gibt es den Weg von der Opportunity aus
(nächster Abschnitt).
### Übernahme zurücknehmen
@@ -331,7 +335,8 @@ deren Eintrag dort noch existiert. Nach einer Rückfrage passiert:
Zurücknehmen darf, wer alle betroffenen Opportunities löschen darf (Ersteller,
Zuständiger oder Administrator). Holt jemand eine davon aus dem Papierkorb
zurück, hängt der Eintrag wieder an ihr und verschwindet aus der Akquise.
zurück, hängt der Eintrag wieder an ihr und steht in der Akquise wieder als
*Übernommen*.
> Bei Übernahmen **vor dem 17.09.2026** kennt nur die jeweils *erste*
> Opportunity ihren Eintrag. Bei mehreren Beratern bleiben die übrigen dann
@@ -369,7 +374,9 @@ im Archiv.
### Aus dem Eintrag wird eine Opportunity
Der Knopf **Opportunity** legt sie sofort an: Phase *Erstgespräch Berater*, 10 % Wahrscheinlichkeit,
zuständig ist der angemeldete Benutzer. Mit übernommen werden Titel, Endkunde,
zuständig ist der angemeldete Benutzer. Der Eintrag bleibt danach in der Liste
stehen; an der Stelle des Knopfes steht **Übernommen ✓** in Blau, und ein Klick
darauf öffnet die Opportunity. Mit übernommen werden Titel, Endkunde,
Beschreibung, Tagessatz, Laufzeit, Projektpreis, Zeitraum und nächster Schritt.
Der **Berater** wandert in das gleichnamige Feld der Opportunity und steht damit
in Pipeline, Liste und Kacheln - für wen ein Projekt läuft, ist neben dem Titel
+2 -2
View File
@@ -102,7 +102,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 |
| `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 |
| `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IS_OPEN`, `IN_ARCHIVE`, `NOT_DUE`, `OPPORTUNITY_GONE`, `NOT_DELETED`, `IN_TRASH`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen. Dazu `splitConsultants` / `joinConsultants` / `normalizeConsultants` für das Feld *Berater*, das mehrere Namen kommagetrennt hält (Gegenstück im Frontend: `src/types.ts`) |
| `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 |
| `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 |
@@ -383,7 +383,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
| `components/dashboard/dashboardUi.tsx` | `useCountUp()` (Zahl läuft hoch, respektiert `prefers-reduced-motion`), `StatCard`, `SeriesChart`, `BarList`, `Panel` - von Hand statt Diagrammbibliothek |
| `components/acquisition/Acquisitions.tsx` | Akquise und Archiv (`view`); Suche, Sortierung, Übernahme, Löschen, Wiederherstellen. Das Formular steht im Archiv nur beim Bearbeiten - ohne diesen Zweig blieb der Stift dort ohne Wirkung |
| `components/acquisition/AcquisitionForm.tsx` | Eingabe über der Liste; behält Datum, Anbieter, Kanal und Berater für den nächsten Eintrag |
| `components/acquisition/AcquisitionTable.tsx` | die Liste; `safeLink()` lässt nur http(s) zum Link werden. Der Pfeil zurück in den Eingang erscheint nur, wenn `onReopen` gesetzt ist - im Eingang gibt es nichts zurückzuholen. Die Titelspalte ist auf 28rem festgelegt und bricht um (`wrap-anywhere`), lange Titel liefen sonst in die Spalte „Berater“ |
| `components/acquisition/AcquisitionTable.tsx` | die Liste; `safeLink()` lässt nur http(s) zum Link werden. `isConverted()` (übernommen und die Opportunity lebt noch) schaltet die Zeile um: statt *Opportunity* steht dort der blaue Knopf **Übernommen ✓**, der zur Opportunity springt, Stift und Papierkorb sind gesperrt und der Pfeil zurück in den Eingang fehlt. Der Pfeil erscheint ohnehin nur, wenn `onReopen` gesetzt ist - im Eingang gibt es nichts zurückzuholen. Die Titelspalte ist auf 28rem festgelegt und bricht um (`wrap-anywhere`), lange Titel liefen sonst in die Spalte „Berater“ |
| `components/acquisition/AcquisitionTrash.tsx` | Papierkorb der Akquise: zurückholen, endgültig löschen, leeren. Die Meldung nach dem Zurückholen nennt das Ziel (Eingang oder Archiv) - das Funddatum bleibt, also landet ein alter Fund im Archiv |
| `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen; `alwaysOffer` hält den eigenen Namen für *Von* immer wählbar. *Anbieter* nutzt es nicht: Freitext mit `<datalist>`. Dazu `ConsultantSelect` (mehrere Berater: Auswahlfeld hängt an, Plaketten nehmen weg) und `ConsultantList` für die Anzeige in der Liste |
| `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen |