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 die ein Job umsetzen müsste, kann von der Wirklichkeit abweichen - eine
gerechnete Grenze nicht. `converted_at` ist dabei die Wahrheit, nicht gerechnete Grenze nicht. `converted_at` ist dabei die Wahrheit, nicht
`opportunity_id`: wird die Opportunity gelöscht, fällt der Verweis auf NULL, `opportunity_id`: wird die Opportunity gelöscht, fällt der Verweis auf NULL,
der Eintrag soll aber nicht wieder im **Eingang** auftauchen - im **Archiv** der Eintrag gehört dann aber ins **Archiv** und nicht in den **Eingang**.
schon. Ob eine übernommene Bewerbung dort wieder erscheint, wird aus dem Übernommene Einträge bleiben in ihrer Liste stehen und sind dort als
Zustand der Opportunity abgeleitet (Papierkorb oder weg), nicht gespeichert: *Übernommen* gekennzeichnet; erst als **Rückfaller** (`RETURNED`: übernommen
wird sie zurückgeholt, verschwindet der Eintrag von selbst wieder. Die Regeln und die Opportunity im Papierkorb oder weg) wechseln sie ins Archiv,
dafür stehen in `server/src/acquisitionRules.js`. 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: 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` - 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 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 vorher nur die Zahl für das Dashboard (Tag, Berater, Anzahl), die
Projektdaten sind dann weg. Wer ein Projekt behalten will, übernimmt es als Projektdaten sind dann weg. Wer ein Projekt behalten will, übernimmt es als
Opportunity. Opportunity.
* Wird ein Eintrag **übernommen**, verschwindet er aus Eingang und Archiv - er * Wird ein Eintrag **übernommen**, **bleibt er in der Liste stehen**. Der Knopf
lebt als Opportunity weiter. wird blau und heißt **Übernommen ✓**; ein Klick darauf springt zur
* Wird diese **Opportunity gelöscht**, taucht die Bewerbung wieder im **Archiv** Opportunity. Bearbeiten und Löschen sind dann gesperrt - gepflegt wird in der
auf, gekennzeichnet mit *Opportunity im Papierkorb* bzw. *Opportunity Opportunity, und der Eintrag bleibt als Herkunftsnachweis erhalten. Nach den
gelöscht*. Ohne das wäre sie nirgends mehr zu finden: aus der Akquise ist sie üblichen 14 Tagen rutscht er ins Archiv wie jeder andere Eintrag.
mit der Übernahme heraus, und die Opportunity gibt es nicht mehr. Sie lässt * Wird diese **Opportunity gelöscht**, wandert die Bewerbung ins **Archiv** -
sich dann wieder bearbeiten und erneut übernehmen. Holt jemand die Opportunity unabhängig von ihrem Alter -, gekennzeichnet mit *Opportunity im Papierkorb*
aus ihrem Papierkorb zurück, verschwindet der Eintrag von selbst wieder - und bzw. *Opportunity gelöscht*. Sie zeigt dort wieder den grünen Knopf
auch ein solcher Rückfaller wird 90 Tage nach seinem Funddatum gelöscht. *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 Verschoben wird dabei nichts: Eingang und Archiv sind dieselbe Liste, nur nach
dem Funddatum geteilt. Es gibt also keinen Zustand, der klemmen kann. 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 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 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 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 Bei einem Eintrag, dessen Opportunity noch lebt, fehlt der Pfeil: er ist
steht ohnehin in keiner der beiden Listen. Dafür gibt es den Weg von der übernommen und gehört zu ihr. Dafür gibt es den Weg von der Opportunity aus
Opportunity aus (nächster Abschnitt). (nächster Abschnitt).
### Übernahme zurücknehmen ### Ü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, Zurücknehmen darf, wer alle betroffenen Opportunities löschen darf (Ersteller,
Zuständiger oder Administrator). Holt jemand eine davon aus dem Papierkorb 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* > Bei Übernahmen **vor dem 17.09.2026** kennt nur die jeweils *erste*
> Opportunity ihren Eintrag. Bei mehreren Beratern bleiben die übrigen dann > Opportunity ihren Eintrag. Bei mehreren Beratern bleiben die übrigen dann
@@ -369,7 +374,9 @@ im Archiv.
### Aus dem Eintrag wird eine Opportunity ### Aus dem Eintrag wird eine Opportunity
Der Knopf **Opportunity** legt sie sofort an: Phase *Erstgespräch Berater*, 10 % Wahrscheinlichkeit, 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. Beschreibung, Tagessatz, Laufzeit, Projektpreis, Zeitraum und nächster Schritt.
Der **Berater** wandert in das gleichnamige Feld der Opportunity und steht damit 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 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 | | `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 |
| `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 | | `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 | | `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/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/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/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/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/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen; `alwaysOffer` hält den eigenen Namen für *Von* immer wählbar. *Anbieter* nutzt es nicht: Freitext mit `<datalist>`. Dazu `ConsultantSelect` (mehrere Berater: Auswahlfeld hängt an, Plaketten nehmen weg) und `ConsultantList` für die Anzeige in der Liste |
| `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen | | `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen |
+24 -6
View File
@@ -44,18 +44,36 @@ export const IN_TRASH = 'a.deleted_at IS NOT NULL';
export const OPPORTUNITY_GONE = '(opp.id IS NULL OR opp.deleted_at IS NOT NULL)'; export const OPPORTUNITY_GONE = '(opp.id IS NULL OR opp.deleted_at IS NOT NULL)';
/** /**
* Bewerbung, die (wieder) in Eingang oder Archiv gehoert: nie uebernommen, oder * Bewerbung, die noch nicht abgearbeitet ist: nie uebernommen, oder die
* die Opportunity ist weg. Abgeleitet statt gespeichert - wird die Opportunity * Opportunity ist weg. Abgeleitet statt gespeichert - wird die Opportunity
* zurueckgeholt, verschwindet der Eintrag von selbst wieder. * zurueckgeholt, gilt der Eintrag von selbst wieder als uebernommen.
*
* Fuer die Listen entscheidet das nichts mehr (uebernommene Eintraege bleiben
* stehen und zeigen dort "Uebernommen"), wohl aber fuer den Loeschlauf: was zu
* einer lebenden Opportunity gehoert, wird nie weggeraeumt.
*/ */
export const IS_OPEN = `(a.converted_at IS NULL OR ${OPPORTUNITY_GONE})`; export const IS_OPEN = `(a.converted_at IS NULL OR ${OPPORTUNITY_GONE})`;
/** /**
* Steht im Archiv: alles Uebernommene, dessen Opportunity weg ist (unabhaengig * Rueckfaeller: schon einmal uebernommen, aber die Opportunity ist weg. Ein
* vom Alter - es ist abgearbeitet, aber nicht verschwunden), plus alles, was * solcher Eintrag gehoert ins Archiv - unabhaengig vom Alter, er ist
* abgearbeitet, aber nicht verschwunden.
*/
export const RETURNED = `(a.converted_at IS NOT NULL AND ${OPPORTUNITY_GONE})`;
/**
* Steht im Eingang: alles innerhalb der Archivfrist, ausser Rueckfaellern.
* Uebernommene Eintraege bleiben hier stehen: verschwaenden sie sofort, bliebe
* beim Durchgehen offen, ob ein Fund schon angefasst wurde. Erwartet die
* Archivfrist als Platzhalter.
*/
export const IN_INBOX = `(NOT ${RETURNED} AND a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY))`;
/**
* Steht im Archiv: jeder Rueckfaeller (unabhaengig vom Alter), plus alles, was
* aelter als die Archivfrist ist. Erwartet die Frist als Platzhalter. * aelter als die Archivfrist ist. Erwartet die Frist als Platzhalter.
*/ */
export const IN_ARCHIVE = '(a.converted_at IS NOT NULL OR a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY))'; export const IN_ARCHIVE = `(${RETURNED} OR a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY))`;
/** Untere Grenze: was schon faellig ist, wird nicht mehr gezeigt. */ /** Untere Grenze: was schon faellig ist, wird nicht mehr gezeigt. */
export const NOT_DUE = 'a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)'; export const NOT_DUE = 'a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)';
+20 -9
View File
@@ -7,6 +7,7 @@ import {
CONSULTANT_SEPARATOR, CONSULTANT_SEPARATOR,
DELETE_AFTER_DAYS, DELETE_AFTER_DAYS,
IN_ARCHIVE, IN_ARCHIVE,
IN_INBOX,
IN_TRASH, IN_TRASH,
IS_OPEN, IS_OPEN,
NOT_DELETED, NOT_DELETED,
@@ -611,8 +612,8 @@ router.delete('/options/:id', async (req, res, next) => {
async function listMeta() { async function listMeta() {
const [[counts]] = await pool.query( const [[counts]] = await pool.query(
`SELECT `SELECT
SUM(${IS_OPEN} AND ${NOT_DELETED} AND a.converted_at IS NULL AND ${NOT_DUE}) AS eingang, SUM(${NOT_DELETED} AND ${IN_INBOX}) AS eingang,
SUM(${IS_OPEN} AND ${NOT_DELETED} AND ${IN_ARCHIVE} AND ${NOT_DUE}) AS archiv, SUM(${NOT_DELETED} AND ${IN_ARCHIVE} AND ${NOT_DUE}) AS archiv,
SUM(${IN_TRASH}) AS papierkorb SUM(${IN_TRASH}) AS papierkorb
FROM crm_acquisitions a FROM crm_acquisitions a
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id`, LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id`,
@@ -660,17 +661,17 @@ router.get('/', async (req, res, next) => {
}); });
} }
const where = [IS_OPEN, NOT_DELETED]; const where = [NOT_DELETED];
const params = []; const params = [];
/* /*
* Der Eingang zeigt nur, was noch nie uebernommen wurde. Eine Bewerbung, * Der Eingang zeigt auch Uebernommenes: der Eintrag bleibt stehen und ist
* deren Opportunity geloescht wurde, gehoert ins Archiv - unabhaengig davon, * dort als uebernommen gekennzeichnet. Nur eine Bewerbung, deren
* wie alt sie ist: sie ist abgearbeitet, aber nicht verschwunden. * Opportunity geloescht wurde, gehoert ins Archiv - unabhaengig davon, wie
* alt sie ist: sie ist abgearbeitet, aber nicht verschwunden.
*/ */
if (view === 'eingang') { if (view === 'eingang') {
where.push('a.converted_at IS NULL'); where.push(IN_INBOX);
where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)');
params.push(ARCHIVE_AFTER_DAYS); params.push(ARCHIVE_AFTER_DAYS);
} else if (view === 'archiv') { } else if (view === 'archiv') {
// Die untere Grenze steht bewusst auch hier: der Loeschlauf raeumt nur // Die untere Grenze steht bewusst auch hier: der Loeschlauf raeumt nur
@@ -867,7 +868,7 @@ router.delete('/:id', async (req, res, next) => {
try { try {
const [rows] = await pool.query( const [rows] = await pool.query(
'SELECT created_by, deleted_at FROM crm_acquisitions WHERE id = ? LIMIT 1', 'SELECT created_by, deleted_at, converted_at, opportunity_id FROM crm_acquisitions WHERE id = ? LIMIT 1',
[id] [id]
); );
const existing = rows[0]; const existing = rows[0];
@@ -875,6 +876,16 @@ router.delete('/:id', async (req, res, next) => {
if (existing.deleted_at !== null) { if (existing.deleted_at !== null) {
return res.status(409).json({ error: 'Dieser Eintrag liegt schon im Papierkorb.' }); return res.status(409).json({ error: 'Dieser Eintrag liegt schon im Papierkorb.' });
} }
/*
* Ein uebernommener Eintrag bleibt stehen, solange seine Opportunity lebt:
* er ist deren Herkunftsnachweis, und "Zurueck in die Akquise" braucht ihn.
* Erst wenn die Opportunity weg ist, ist er wieder ein gewoehnlicher Fund.
*/
if (existing.converted_at !== null && !(await opportunityGone(existing.opportunity_id))) {
return res.status(409).json({
error: 'Der Eintrag gehört zu einer Opportunity. Erst die Opportunity löschen oder zurücknehmen.',
});
}
if (!mayModify(req.user, existing)) { if (!mayModify(req.user, existing)) {
return res.status(403).json({ return res.status(403).json({
error: 'Diesen Eintrag darf nur löschen, wer ihn angelegt hat, oder ein Administrator.', error: 'Diesen Eintrag darf nur löschen, wer ihn angelegt hat, oder ein Administrator.',
+1 -2
View File
@@ -8,7 +8,6 @@ import {
DELETE_AFTER_DAYS, DELETE_AFTER_DAYS,
IN_ARCHIVE, IN_ARCHIVE,
IN_TRASH, IN_TRASH,
IS_OPEN,
NOT_DELETED, NOT_DELETED,
NOT_DUE, NOT_DUE,
} from '../acquisitionRules.js'; } from '../acquisitionRules.js';
@@ -38,7 +37,7 @@ router.get('/', async (req, res, next) => {
damit Zahl und Liste nicht auseinanderlaufen. */ damit Zahl und Liste nicht auseinanderlaufen. */
(SELECT COUNT(*) FROM crm_acquisitions a (SELECT COUNT(*) FROM crm_acquisitions a
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
WHERE ${IS_OPEN} AND ${NOT_DELETED} AND ${IN_ARCHIVE} WHERE ${NOT_DELETED} AND ${IN_ARCHIVE}
AND ${NOT_DUE}) AS acquisition_archive, AND ${NOT_DUE}) AS acquisition_archive,
(SELECT COUNT(*) FROM crm_acquisitions a WHERE ${IN_TRASH}) AS acquisition_trash, (SELECT COUNT(*) FROM crm_acquisitions a WHERE ${IN_TRASH}) AS acquisition_trash,
(SELECT COUNT(*) FROM crm_opportunities (SELECT COUNT(*) FROM crm_opportunities
@@ -26,6 +26,8 @@ interface AcquisitionTableProps {
onEdit: (entry: Acquisition) => void; onEdit: (entry: Acquisition) => void;
onDelete: (entry: Acquisition) => void; onDelete: (entry: Acquisition) => void;
onConvert: (entry: Acquisition) => void; onConvert: (entry: Acquisition) => void;
/** Sprung zur bereits angelegten Opportunity. */
onOpenOpportunity: (entry: Acquisition) => void;
/** Nur im Archiv gesetzt - im Eingang gibt es nichts zurueckzuholen. */ /** Nur im Archiv gesetzt - im Eingang gibt es nichts zurueckzuholen. */
onReopen?: (entry: Acquisition) => void; onReopen?: (entry: Acquisition) => void;
} }
@@ -76,13 +78,22 @@ function safeLink(value: string | null): string | null {
return /^https?:\/\//i.test(value) ? value : null; return /^https?:\/\//i.test(value) ? value : null;
} }
/**
* Uebernommen und die Opportunity lebt noch. Ein Rueckfaeller (Opportunity im
* Papierkorb oder geloescht) zaehlt nicht dazu - der darf erneut uebernommen
* werden und bekommt deshalb wieder den normalen Knopf.
*/
function isConverted(entry: Acquisition): boolean {
return entry.converted_at !== null && !entry.opportunity_gone;
}
/** /**
* Kennzeichen fuer eine Bewerbung, die ins Archiv zurueckgefallen ist: sie war * Kennzeichen fuer eine Bewerbung, die ins Archiv zurueckgefallen ist: sie war
* schon eine Opportunity, und die ist weg. Ohne den Hinweis stuende sie * schon eine Opportunity, und die ist weg. Ohne den Hinweis stuende sie
* unerklaerlich im Archiv. * unerklaerlich im Archiv.
*/ */
function returnedNote(entry: Acquisition): string | null { function returnedNote(entry: Acquisition): string | null {
if (entry.converted_at === null) return null; if (entry.converted_at === null || isConverted(entry)) return null;
if (entry.opportunity_deleted_at) return 'Opportunity im Papierkorb'; if (entry.opportunity_deleted_at) return 'Opportunity im Papierkorb';
return 'Opportunity gelöscht'; return 'Opportunity gelöscht';
} }
@@ -119,6 +130,7 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
onEdit, onEdit,
onDelete, onDelete,
onConvert, onConvert,
onOpenOpportunity,
onReopen, onReopen,
}) => ( }) => (
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card"> <div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card">
@@ -156,6 +168,7 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
const extraParts = extras(entry); const extraParts = extras(entry);
const returned = returnedNote(entry); const returned = returnedNote(entry);
const isBusy = busyId === entry.id; const isBusy = busyId === entry.id;
const converted = isConverted(entry);
// Entscheidet nur die Beschriftung: bei mehreren Beratern entsteht // Entscheidet nur die Beschriftung: bei mehreren Beratern entsteht
// beim Uebernehmen je Berater eine Opportunity. // beim Uebernehmen je Berater eine Opportunity.
const consultantCount = splitConsultants(entry.consultant).length; const consultantCount = splitConsultants(entry.consultant).length;
@@ -221,13 +234,33 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{/*
Ein uebernommener Eintrag bleibt in der Liste stehen - der
Knopf wird blau und fuehrt zur angelegten Opportunity.
*/}
{converted ? (
<button
onClick={() => onOpenOpportunity(entry)}
title={
entry.opportunity_title
? `Übernommen: „${entry.opportunity_title}“ öffnen`
: 'Übernommen - Opportunity öffnen'
}
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl bg-blue-600 px-3 py-1.5 text-xs font-bold text-white shadow-card transition hover:bg-blue-700"
>
Übernommen
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.6} d="M5 13l4 4L19 7" />
</svg>
</button>
) : (
<button <button
onClick={() => onConvert(entry)} onClick={() => onConvert(entry)}
disabled={isBusy} disabled={isBusy}
title={ title={
consultantCount > 1 consultantCount > 1
? `Je Berater eine Opportunity in der Phase „Erstgespräch Berater“ anlegen - ${consultantCount} insgesamt. Der Eintrag verschwindet danach aus dieser Liste.` ? `Je Berater eine Opportunity in der Phase „Erstgespräch Berater“ anlegen - ${consultantCount} insgesamt. Der Eintrag bleibt stehen und ist danach als übernommen gekennzeichnet.`
: 'Als neue Opportunity in der Phase „Erstgespräch Berater“ anlegen. Der Eintrag verschwindet danach aus dieser Liste.' : 'Als neue Opportunity in der Phase „Erstgespräch Berater“ anlegen. Der Eintrag bleibt stehen und ist danach als übernommen gekennzeichnet.'
} }
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl bg-brand-500 px-3 py-1.5 text-xs font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-50" className="flex items-center gap-1.5 whitespace-nowrap rounded-xl bg-brand-500 px-3 py-1.5 text-xs font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-50"
> >
@@ -236,11 +269,12 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
</svg> </svg>
{isBusy ? 'Lege an...' : 'Opportunity'} {isBusy ? 'Lege an...' : 'Opportunity'}
</button> </button>
)}
</td> </td>
<td className="whitespace-nowrap px-4 py-3 text-right"> <td className="whitespace-nowrap px-4 py-3 text-right">
<div className="inline-flex gap-1"> <div className="inline-flex gap-1">
{onReopen && ( {onReopen && !converted && (
<button <button
onClick={() => onReopen(entry)} onClick={() => onReopen(entry)}
disabled={isBusy} disabled={isBusy}
@@ -260,9 +294,14 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
)} )}
<button <button
onClick={() => onEdit(entry)} onClick={() => onEdit(entry)}
title="Bearbeiten" disabled={converted}
title={
converted
? 'Übernommen - gepflegt wird jetzt in der Opportunity.'
: 'Bearbeiten'
}
aria-label={`${entry.title} bearbeiten`} aria-label={`${entry.title} bearbeiten`}
className="rounded-lg p-2 text-ink-400 transition hover:bg-brand-50 hover:text-brand-700" className="rounded-lg p-2 text-ink-400 transition hover:bg-brand-50 hover:text-brand-700 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-ink-400"
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
@@ -275,9 +314,14 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
</button> </button>
<button <button
onClick={() => onDelete(entry)} onClick={() => onDelete(entry)}
title="Löschen" disabled={converted}
title={
converted
? 'Übernommen - der Eintrag gehört zur Opportunity und bleibt als Herkunftsnachweis stehen.'
: 'Löschen'
}
aria-label={`${entry.title} löschen`} aria-label={`${entry.title} löschen`}
className="rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600" className="rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-ink-400"
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
@@ -152,6 +152,16 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
} }
}; };
/*
* Sprung zur Opportunity, die aus dem Eintrag entstanden ist. Bei mehreren
* Beratern merkt sich der Eintrag die erste - die Liste zeigt daneben ohnehin
* alle, die zum Projekt gehoeren.
*/
const openOpportunity = (entry: Acquisition) => {
if (entry.opportunity_id === null) return;
goTo('Opportunities', { opportunityId: entry.opportunity_id });
};
const reopen = async (entry: Acquisition) => { const reopen = async (entry: Acquisition) => {
setBusyId(entry.id); setBusyId(entry.id);
try { try {
@@ -311,6 +321,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
onEdit={setEditing} onEdit={setEditing}
onDelete={setToDelete} onDelete={setToDelete}
onConvert={convert} onConvert={convert}
onOpenOpportunity={openOpportunity}
onReopen={isArchive ? handleReopen : undefined} onReopen={isArchive ? handleReopen : undefined}
/> />
)} )}