diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index cff223e..5f9cb59 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -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 diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index 83560ee..97494ff 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -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 diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index 255bded..bee3cda 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -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 ``. 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 | diff --git a/server/src/acquisitionRules.js b/server/src/acquisitionRules.js index 8b667b3..5e58493 100644 --- a/server/src/acquisitionRules.js +++ b/server/src/acquisitionRules.js @@ -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)'; /** - * Bewerbung, die (wieder) in Eingang oder Archiv gehoert: nie uebernommen, oder - * die Opportunity ist weg. Abgeleitet statt gespeichert - wird die Opportunity - * zurueckgeholt, verschwindet der Eintrag von selbst wieder. + * Bewerbung, die noch nicht abgearbeitet ist: nie uebernommen, oder die + * Opportunity ist weg. Abgeleitet statt gespeichert - wird die Opportunity + * 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})`; /** - * Steht im Archiv: alles Uebernommene, dessen Opportunity weg ist (unabhaengig - * vom Alter - es ist abgearbeitet, aber nicht verschwunden), plus alles, was + * Rueckfaeller: schon einmal uebernommen, aber die Opportunity ist weg. Ein + * 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. */ -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. */ export const NOT_DUE = 'a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)'; diff --git a/server/src/routes/acquisitions.js b/server/src/routes/acquisitions.js index b27aa2f..fc8aa95 100644 --- a/server/src/routes/acquisitions.js +++ b/server/src/routes/acquisitions.js @@ -7,6 +7,7 @@ import { CONSULTANT_SEPARATOR, DELETE_AFTER_DAYS, IN_ARCHIVE, + IN_INBOX, IN_TRASH, IS_OPEN, NOT_DELETED, @@ -611,9 +612,9 @@ router.delete('/options/:id', async (req, res, next) => { async function listMeta() { const [[counts]] = await pool.query( `SELECT - SUM(${IS_OPEN} AND ${NOT_DELETED} AND a.converted_at IS NULL AND ${NOT_DUE}) AS eingang, - SUM(${IS_OPEN} AND ${NOT_DELETED} AND ${IN_ARCHIVE} AND ${NOT_DUE}) AS archiv, - SUM(${IN_TRASH}) AS papierkorb + SUM(${NOT_DELETED} AND ${IN_INBOX}) AS eingang, + SUM(${NOT_DELETED} AND ${IN_ARCHIVE} AND ${NOT_DUE}) AS archiv, + SUM(${IN_TRASH}) AS papierkorb FROM crm_acquisitions a LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id`, // Reihenfolge der Platzhalter: Eingangsfrist, Archivfrist, Loeschfrist. @@ -660,17 +661,17 @@ router.get('/', async (req, res, next) => { }); } - const where = [IS_OPEN, NOT_DELETED]; + const where = [NOT_DELETED]; const params = []; /* - * Der Eingang zeigt nur, was noch nie uebernommen wurde. Eine Bewerbung, - * deren Opportunity geloescht wurde, gehoert ins Archiv - unabhaengig davon, - * wie alt sie ist: sie ist abgearbeitet, aber nicht verschwunden. + * Der Eingang zeigt auch Uebernommenes: der Eintrag bleibt stehen und ist + * dort als uebernommen gekennzeichnet. Nur eine Bewerbung, deren + * Opportunity geloescht wurde, gehoert ins Archiv - unabhaengig davon, wie + * alt sie ist: sie ist abgearbeitet, aber nicht verschwunden. */ if (view === 'eingang') { - where.push('a.converted_at IS NULL'); - where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)'); + where.push(IN_INBOX); params.push(ARCHIVE_AFTER_DAYS); } else if (view === 'archiv') { // Die untere Grenze steht bewusst auch hier: der Loeschlauf raeumt nur @@ -867,7 +868,7 @@ router.delete('/:id', async (req, res, next) => { try { 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] ); const existing = rows[0]; @@ -875,6 +876,16 @@ router.delete('/:id', async (req, res, next) => { if (existing.deleted_at !== null) { 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)) { return res.status(403).json({ error: 'Diesen Eintrag darf nur löschen, wer ihn angelegt hat, oder ein Administrator.', diff --git a/server/src/routes/badges.js b/server/src/routes/badges.js index fc05b15..b3cee48 100644 --- a/server/src/routes/badges.js +++ b/server/src/routes/badges.js @@ -8,7 +8,6 @@ import { DELETE_AFTER_DAYS, IN_ARCHIVE, IN_TRASH, - IS_OPEN, NOT_DELETED, NOT_DUE, } from '../acquisitionRules.js'; @@ -38,7 +37,7 @@ router.get('/', async (req, res, next) => { damit Zahl und Liste nicht auseinanderlaufen. */ (SELECT COUNT(*) FROM crm_acquisitions a 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, (SELECT COUNT(*) FROM crm_acquisitions a WHERE ${IN_TRASH}) AS acquisition_trash, (SELECT COUNT(*) FROM crm_opportunities diff --git a/src/components/acquisition/AcquisitionTable.tsx b/src/components/acquisition/AcquisitionTable.tsx index ad64fda..c1b597f 100644 --- a/src/components/acquisition/AcquisitionTable.tsx +++ b/src/components/acquisition/AcquisitionTable.tsx @@ -26,6 +26,8 @@ interface AcquisitionTableProps { onEdit: (entry: Acquisition) => void; onDelete: (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. */ onReopen?: (entry: Acquisition) => void; } @@ -76,13 +78,22 @@ function safeLink(value: string | null): string | 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 * schon eine Opportunity, und die ist weg. Ohne den Hinweis stuende sie * unerklaerlich im Archiv. */ 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'; return 'Opportunity gelöscht'; } @@ -119,6 +130,7 @@ export const AcquisitionTable: React.FC = ({ onEdit, onDelete, onConvert, + onOpenOpportunity, onReopen, }) => (
@@ -156,6 +168,7 @@ export const AcquisitionTable: React.FC = ({ const extraParts = extras(entry); const returned = returnedNote(entry); const isBusy = busyId === entry.id; + const converted = isConverted(entry); // Entscheidet nur die Beschriftung: bei mehreren Beratern entsteht // beim Uebernehmen je Berater eine Opportunity. const consultantCount = splitConsultants(entry.consultant).length; @@ -221,26 +234,47 @@ export const AcquisitionTable: React.FC = ({ - + {/* + Ein uebernommener Eintrag bleibt in der Liste stehen - der + Knopf wird blau und fuehrt zur angelegten Opportunity. + */} + {converted ? ( + + ) : ( + + )}
- {onReopen && ( + {onReopen && !converted && (