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
+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)';
/**
* 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)';
+21 -10
View File
@@ -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.',
+1 -2
View File
@@ -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