From c380d05b420e1ca45701c768cc09e00a31fd1316 Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Tue, 18 Aug 2026 15:52:35 +0200 Subject: [PATCH] feat(akquise): geloeschte Opportunity gibt die Bewerbung ans Archiv zurueck Wer eine Opportunity loescht, hat bisher auch die Bewerbung dahinter verloren: aus der Akquise war sie mit der Uebernahme heraus, und die Opportunity gab es nicht mehr. Der Eintrag stand nirgends. Jetzt taucht er im Archiv auf - abgeleitet, nicht gespeichert: `converted_at` bleibt stehen, sichtbar wird der Eintrag, solange seine Opportunity im Papierkorb liegt oder endgueltig weg ist. Wird sie zurueckgeholt, verschwindet der Eintrag von selbst wieder. Es gibt damit keinen Zustand, der nachgezogen werden muesste und auseinanderlaufen kann. Die Regel steht als IS_OPEN an einer Stelle und gilt fuer Liste, Zaehler und den Aufraeumlauf: ein Rueckfaeller altert wie jeder andere Eintrag nach 90 Tagen heraus, und seine Anzahl wird vorher in den Verlauf gesichert. Ausserdem: - Bearbeiten und erneutes Uebernehmen sind wieder erlaubt, sobald die Opportunity weg ist. Gesperrt bleibt der Eintrag nur, solange sie lebt - dort wird dann gepflegt. - Der Eingang zeigt weiter nur, was nie uebernommen wurde. - Die Zeile traegt ein Kennzeichen ("Opportunity im Papierkorb" bzw. "Opportunity geloescht"), sonst stuende sie unerklaerlich im Archiv. - Im Dashboard zaehlt eine Wandlung nur mit lebender Opportunity. Wird sie geloescht, faellt die Bewerbung zurueck - dann soll die Quote nicht behaupten, aus ihr sei eine Opportunity geworden. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/routes/acquisitions.js | 102 ++++++++++++++---- server/src/routes/dashboard.js | 14 ++- .../acquisition/AcquisitionTable.tsx | 24 +++++ src/components/acquisition/Acquisitions.tsx | 5 + src/types.ts | 10 +- 5 files changed, 132 insertions(+), 23 deletions(-) diff --git a/server/src/routes/acquisitions.js b/server/src/routes/acquisitions.js index 3c07791..8b293fd 100644 --- a/server/src/routes/acquisitions.js +++ b/server/src/routes/acquisitions.js @@ -19,6 +19,26 @@ router.use(requireAuth); * woher eine Opportunity kommt. Nur der Eingang und das Archiv zeigen sie nicht. */ +/* + * Ist die Opportunity zu einem Eintrag verschwunden - im Papierkorb oder + * endgueltig geloescht -, faellt die Bewerbung ins Archiv zurueck. Sonst wuerde + * sie einfach nicht mehr existieren: aus der Akquise ist sie mit der Uebernahme + * heraus, und die Opportunity gibt es auch nicht mehr. + * + * Abgeleitet statt gespeichert: `converted_at` bleibt stehen. Wird die + * Opportunity aus dem Papierkorb zurueckgeholt, verschwindet der Eintrag von + * selbst wieder aus dem Archiv - es gibt keinen Zustand, der nachgezogen werden + * muesste. + * + * `opp.id IS NULL` deckt zwei Faelle ab: nie uebernommen (opportunity_id ist + * NULL) und endgueltig geloescht (der Fremdschluessel wurde dabei auf NULL + * gesetzt). Zusammen mit converted_at ergibt das den Unterschied. + */ +const OPPORTUNITY_GONE = '(opp.id IS NULL OR opp.deleted_at IS NOT NULL)'; + +/** Bewerbung, die (wieder) in Eingang oder Archiv gehoert. */ +const IS_OPEN = `(a.converted_at IS NULL OR ${OPPORTUNITY_GONE})`; + /** Grenze zwischen Eingang und Archiv, in Tagen. */ const ARCHIVE_AFTER_DAYS = 14; /** Ab hier loescht das Archiv selbst. */ @@ -139,6 +159,8 @@ function serializeTags(value) { function mapAcquisition(row) { return { ...row, + // Aus der Abfrage kommt 0/1 - im Frontend soll es ein Schalter sein. + opportunity_gone: Boolean(Number(row.opportunity_gone)), daily_rate: row.daily_rate === null ? null : Number(row.daily_rate), duration_days: row.duration_days === null ? null : Number(row.duration_days), fixed_price: row.fixed_price === null ? null : Number(row.fixed_price), @@ -148,9 +170,11 @@ function mapAcquisition(row) { const SELECT_ACQUISITIONS = ` SELECT a.*, - creator.name AS created_by_name, - opp.title AS opportunity_title, - opp.stage AS opportunity_stage + creator.name AS created_by_name, + opp.title AS opportunity_title, + opp.stage AS opportunity_stage, + opp.deleted_at AS opportunity_deleted_at, + ${OPPORTUNITY_GONE} AS opportunity_gone FROM crm_acquisitions a LEFT JOIN crm_users creator ON creator.id = a.created_by LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id @@ -190,20 +214,27 @@ async function purgeOldEntries() { await conn.beginTransaction(); await conn.query( `INSERT INTO crm_acquisition_history (found_on, consultant, applications) - SELECT found_at, COALESCE(consultant, ''), COUNT(*) - FROM crm_acquisitions - WHERE converted_at IS NULL - AND found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY) - GROUP BY found_at, COALESCE(consultant, '') + SELECT a.found_at, COALESCE(a.consultant, ''), COUNT(*) + FROM crm_acquisitions a + LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id + WHERE ${IS_OPEN} + AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY) + GROUP BY a.found_at, COALESCE(a.consultant, '') ON DUPLICATE KEY UPDATE applications = crm_acquisition_history.applications + VALUES(applications)`, [DELETE_AFTER_DAYS] ); + /* + * Geloescht wird ueber dieselbe Regel: was in Eingang oder Archiv steht, + * altert nach 90 Tagen heraus - auch ein Rueckfaeller. Eintraege mit + * lebender Opportunity bleiben unberuehrt, sie gehoeren zu ihr. + */ const [result] = await conn.query( - `DELETE FROM crm_acquisitions - WHERE converted_at IS NULL - AND found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`, + `DELETE a FROM crm_acquisitions a + LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id + WHERE ${IS_OPEN} + AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`, [DELETE_AFTER_DAYS] ); await conn.commit(); @@ -248,6 +279,20 @@ async function loadProviderSuggestions() { return rows.map((row) => row.provider); } +/** + * Ist die Opportunity zu einem Eintrag verschwunden? Dieselbe Frage wie + * OPPORTUNITY_GONE, nur fuer eine einzelne Zeile - beim Bearbeiten und beim + * erneuten Uebernehmen. + */ +async function opportunityGone(opportunityId) { + if (opportunityId === null || opportunityId === undefined) return true; + const [rows] = await pool.query( + 'SELECT deleted_at FROM crm_opportunities WHERE id = ? LIMIT 1', + [opportunityId] + ); + return !rows[0] || rows[0].deleted_at !== null; +} + /** Aendern und loeschen darf der Ersteller und jeder Admin. */ function mayModify(user, row) { return user.role === 'admin' || row.created_by === user.id; @@ -441,16 +486,25 @@ router.get('/', async (req, res, next) => { await purgeOldEntries(); const view = String(req.query.view ?? 'eingang'); - const where = ['a.converted_at IS NULL']; + const where = [IS_OPEN]; 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. + */ if (view === 'eingang') { + where.push('a.converted_at IS NULL'); where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)'); params.push(ARCHIVE_AFTER_DAYS); } else if (view === 'archiv') { // Die untere Grenze steht bewusst auch hier: der Loeschlauf raeumt nur // stuendlich, angezeigt werden soll nichts, was schon faellig ist. - where.push('a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)'); + where.push( + `(a.converted_at IS NOT NULL + OR a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY))` + ); where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)'); params.push(ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS); } else if (view !== 'alle') { @@ -467,10 +521,14 @@ router.get('/', async (req, res, next) => { // muesste sonst zwei Listen laden, um eine Zahl zu kennen. const [[counts]] = await pool.query( `SELECT - SUM(found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS eingang, - SUM(found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY) - AND found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS archiv - FROM crm_acquisitions WHERE converted_at IS NULL`, + SUM(a.converted_at IS NULL + AND a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS eingang, + SUM((a.converted_at IS NOT NULL + OR a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)) + AND a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS archiv + FROM crm_acquisitions a + LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id + WHERE ${IS_OPEN}`, [ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS] ); @@ -530,7 +588,12 @@ router.patch('/:id', async (req, res, next) => { error: 'Diesen Eintrag darf nur ändern, wer ihn angelegt hat, oder ein Administrator.', }); } - if (existing.converted_at !== null) { + /* + * Gesperrt ist der Eintrag nur, solange seine Opportunity lebt - dort wird + * dann gepflegt. Ist sie im Papierkorb oder geloescht, ist der Eintrag + * wieder das Original und darf geaendert werden. + */ + if (existing.converted_at !== null && !(await opportunityGone(existing.opportunity_id))) { return res .status(409) .json({ error: 'Der Eintrag wurde schon in eine Opportunity übernommen.' }); @@ -607,7 +670,8 @@ router.post('/:id/opportunity', async (req, res, next) => { const [rows] = await conn.query('SELECT * FROM crm_acquisitions WHERE id = ? LIMIT 1', [id]); const entry = rows[0]; if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden.' }); - if (entry.converted_at !== null) { + // Noch einmal uebernehmen darf man, wenn die erste Opportunity weg ist. + if (entry.converted_at !== null && !(await opportunityGone(entry.opportunity_id))) { return res.status(409).json({ error: 'Aus diesem Eintrag wurde schon eine Opportunity.', opportunity_id: entry.opportunity_id, diff --git a/server/src/routes/dashboard.js b/server/src/routes/dashboard.js index 9db5a56..72b1aaa 100644 --- a/server/src/routes/dashboard.js +++ b/server/src/routes/dashboard.js @@ -73,10 +73,18 @@ router.get('/', async (req, res, next) => { [from, to] ); + /* + * Gezaehlt wird nur, wenn die Opportunity noch da ist. Wird sie geloescht, + * faellt die Bewerbung ins Archiv zurueck - dann ist aus ihr eben keine + * Opportunity geworden, und die Quote soll das nicht behaupten. + */ const [convertedRows] = await pool.query( - `SELECT ${BUCKETS[bucket]('converted_at')} AS bucket, COUNT(*) AS count - FROM crm_acquisitions - WHERE converted_at IS NOT NULL AND converted_at BETWEEN ? AND ? + `SELECT ${BUCKETS[bucket]('a.converted_at')} AS bucket, COUNT(*) AS count + FROM crm_acquisitions a + JOIN crm_opportunities opp ON opp.id = a.opportunity_id + WHERE a.converted_at IS NOT NULL + AND opp.deleted_at IS NULL + AND a.converted_at BETWEEN ? AND ? GROUP BY bucket`, [from, toExclusive] ); diff --git a/src/components/acquisition/AcquisitionTable.tsx b/src/components/acquisition/AcquisitionTable.tsx index 1501485..ebe5f5c 100644 --- a/src/components/acquisition/AcquisitionTable.tsx +++ b/src/components/acquisition/AcquisitionTable.tsx @@ -73,6 +73,17 @@ function safeLink(value: string | null): string | null { return /^https?:\/\//i.test(value) ? value : null; } +/** + * 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.opportunity_deleted_at) return 'Opportunity im Papierkorb'; + return 'Opportunity gelöscht'; +} + /** Kurze Zusammenfassung der Angaben, die keine eigene Spalte haben. */ function extras(entry: Acquisition): string[] { const parts: string[] = []; @@ -139,6 +150,7 @@ export const AcquisitionTable: React.FC = ({ const found = formatFound(entry.found_at); const link = safeLink(entry.link); const extraParts = extras(entry); + const returned = returnedNote(entry); const isBusy = busyId === entry.id; return ( @@ -183,6 +195,18 @@ export const AcquisitionTable: React.FC = ({ {extraParts.length > 0 && (

{extraParts.join(' · ')}

)} + {returned && ( + + {returned} + + )} {entry.consultant || '—'} diff --git a/src/components/acquisition/Acquisitions.tsx b/src/components/acquisition/Acquisitions.tsx index 5f87c29..41551ad 100644 --- a/src/components/acquisition/Acquisitions.tsx +++ b/src/components/acquisition/Acquisitions.tsx @@ -170,6 +170,11 @@ export const Acquisitions: React.FC = ({ view = 'eingang', re weiter bearbeiten und jederzeit in eine Opportunity übernehmen. Neue Projekte werden unter Akquise eingetragen.

+

+ Hier stehen auch Bewerbungen, deren Opportunity gelöscht{' '} + wurde - sonst wären sie nirgends mehr zu finden. Wird die Opportunity aus ihrem + Papierkorb zurückgeholt, verschwindet der Eintrag wieder von selbst. +

) : (