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. +

) : (