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 {extraParts.join(' · ')}{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
+ 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. +
) : (