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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6a81258ca0
commit
c380d05b42
@@ -19,6 +19,26 @@ router.use(requireAuth);
|
|||||||
* woher eine Opportunity kommt. Nur der Eingang und das Archiv zeigen sie nicht.
|
* 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. */
|
/** Grenze zwischen Eingang und Archiv, in Tagen. */
|
||||||
const ARCHIVE_AFTER_DAYS = 14;
|
const ARCHIVE_AFTER_DAYS = 14;
|
||||||
/** Ab hier loescht das Archiv selbst. */
|
/** Ab hier loescht das Archiv selbst. */
|
||||||
@@ -139,6 +159,8 @@ function serializeTags(value) {
|
|||||||
function mapAcquisition(row) {
|
function mapAcquisition(row) {
|
||||||
return {
|
return {
|
||||||
...row,
|
...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),
|
daily_rate: row.daily_rate === null ? null : Number(row.daily_rate),
|
||||||
duration_days: row.duration_days === null ? null : Number(row.duration_days),
|
duration_days: row.duration_days === null ? null : Number(row.duration_days),
|
||||||
fixed_price: row.fixed_price === null ? null : Number(row.fixed_price),
|
fixed_price: row.fixed_price === null ? null : Number(row.fixed_price),
|
||||||
@@ -150,7 +172,9 @@ const SELECT_ACQUISITIONS = `
|
|||||||
SELECT a.*,
|
SELECT a.*,
|
||||||
creator.name AS created_by_name,
|
creator.name AS created_by_name,
|
||||||
opp.title AS opportunity_title,
|
opp.title AS opportunity_title,
|
||||||
opp.stage AS opportunity_stage
|
opp.stage AS opportunity_stage,
|
||||||
|
opp.deleted_at AS opportunity_deleted_at,
|
||||||
|
${OPPORTUNITY_GONE} AS opportunity_gone
|
||||||
FROM crm_acquisitions a
|
FROM crm_acquisitions a
|
||||||
LEFT JOIN crm_users creator ON creator.id = a.created_by
|
LEFT JOIN crm_users creator ON creator.id = a.created_by
|
||||||
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
||||||
@@ -190,20 +214,27 @@ async function purgeOldEntries() {
|
|||||||
await conn.beginTransaction();
|
await conn.beginTransaction();
|
||||||
await conn.query(
|
await conn.query(
|
||||||
`INSERT INTO crm_acquisition_history (found_on, consultant, applications)
|
`INSERT INTO crm_acquisition_history (found_on, consultant, applications)
|
||||||
SELECT found_at, COALESCE(consultant, ''), COUNT(*)
|
SELECT a.found_at, COALESCE(a.consultant, ''), COUNT(*)
|
||||||
FROM crm_acquisitions
|
FROM crm_acquisitions a
|
||||||
WHERE converted_at IS NULL
|
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
||||||
AND found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
WHERE ${IS_OPEN}
|
||||||
GROUP BY found_at, COALESCE(consultant, '')
|
AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||||
|
GROUP BY a.found_at, COALESCE(a.consultant, '')
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
applications = crm_acquisition_history.applications + VALUES(applications)`,
|
applications = crm_acquisition_history.applications + VALUES(applications)`,
|
||||||
[DELETE_AFTER_DAYS]
|
[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(
|
const [result] = await conn.query(
|
||||||
`DELETE FROM crm_acquisitions
|
`DELETE a FROM crm_acquisitions a
|
||||||
WHERE converted_at IS NULL
|
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
||||||
AND found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`,
|
WHERE ${IS_OPEN}
|
||||||
|
AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`,
|
||||||
[DELETE_AFTER_DAYS]
|
[DELETE_AFTER_DAYS]
|
||||||
);
|
);
|
||||||
await conn.commit();
|
await conn.commit();
|
||||||
@@ -248,6 +279,20 @@ async function loadProviderSuggestions() {
|
|||||||
return rows.map((row) => row.provider);
|
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. */
|
/** Aendern und loeschen darf der Ersteller und jeder Admin. */
|
||||||
function mayModify(user, row) {
|
function mayModify(user, row) {
|
||||||
return user.role === 'admin' || row.created_by === user.id;
|
return user.role === 'admin' || row.created_by === user.id;
|
||||||
@@ -441,16 +486,25 @@ router.get('/', async (req, res, next) => {
|
|||||||
await purgeOldEntries();
|
await purgeOldEntries();
|
||||||
|
|
||||||
const view = String(req.query.view ?? 'eingang');
|
const view = String(req.query.view ?? 'eingang');
|
||||||
const where = ['a.converted_at IS NULL'];
|
const where = [IS_OPEN];
|
||||||
const params = [];
|
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') {
|
if (view === 'eingang') {
|
||||||
|
where.push('a.converted_at IS NULL');
|
||||||
where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)');
|
where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)');
|
||||||
params.push(ARCHIVE_AFTER_DAYS);
|
params.push(ARCHIVE_AFTER_DAYS);
|
||||||
} else if (view === 'archiv') {
|
} else if (view === 'archiv') {
|
||||||
// Die untere Grenze steht bewusst auch hier: der Loeschlauf raeumt nur
|
// Die untere Grenze steht bewusst auch hier: der Loeschlauf raeumt nur
|
||||||
// stuendlich, angezeigt werden soll nichts, was schon faellig ist.
|
// 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)');
|
where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)');
|
||||||
params.push(ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS);
|
params.push(ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS);
|
||||||
} else if (view !== 'alle') {
|
} else if (view !== 'alle') {
|
||||||
@@ -467,10 +521,14 @@ router.get('/', async (req, res, next) => {
|
|||||||
// muesste sonst zwei Listen laden, um eine Zahl zu kennen.
|
// muesste sonst zwei Listen laden, um eine Zahl zu kennen.
|
||||||
const [[counts]] = await pool.query(
|
const [[counts]] = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
SUM(found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS eingang,
|
SUM(a.converted_at IS NULL
|
||||||
SUM(found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
AND a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS eingang,
|
||||||
AND found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS archiv
|
SUM((a.converted_at IS NOT NULL
|
||||||
FROM crm_acquisitions WHERE converted_at IS 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]
|
[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.',
|
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
|
return res
|
||||||
.status(409)
|
.status(409)
|
||||||
.json({ error: 'Der Eintrag wurde schon in eine Opportunity übernommen.' });
|
.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 [rows] = await conn.query('SELECT * FROM crm_acquisitions WHERE id = ? LIMIT 1', [id]);
|
||||||
const entry = rows[0];
|
const entry = rows[0];
|
||||||
if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
|
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({
|
return res.status(409).json({
|
||||||
error: 'Aus diesem Eintrag wurde schon eine Opportunity.',
|
error: 'Aus diesem Eintrag wurde schon eine Opportunity.',
|
||||||
opportunity_id: entry.opportunity_id,
|
opportunity_id: entry.opportunity_id,
|
||||||
|
|||||||
@@ -73,10 +73,18 @@ router.get('/', async (req, res, next) => {
|
|||||||
[from, to]
|
[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(
|
const [convertedRows] = await pool.query(
|
||||||
`SELECT ${BUCKETS[bucket]('converted_at')} AS bucket, COUNT(*) AS count
|
`SELECT ${BUCKETS[bucket]('a.converted_at')} AS bucket, COUNT(*) AS count
|
||||||
FROM crm_acquisitions
|
FROM crm_acquisitions a
|
||||||
WHERE converted_at IS NOT NULL AND converted_at BETWEEN ? AND ?
|
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`,
|
GROUP BY bucket`,
|
||||||
[from, toExclusive]
|
[from, toExclusive]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,6 +73,17 @@ function safeLink(value: string | null): string | null {
|
|||||||
return /^https?:\/\//i.test(value) ? value : 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. */
|
/** Kurze Zusammenfassung der Angaben, die keine eigene Spalte haben. */
|
||||||
function extras(entry: Acquisition): string[] {
|
function extras(entry: Acquisition): string[] {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
@@ -139,6 +150,7 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
|
|||||||
const found = formatFound(entry.found_at);
|
const found = formatFound(entry.found_at);
|
||||||
const link = safeLink(entry.link);
|
const link = safeLink(entry.link);
|
||||||
const extraParts = extras(entry);
|
const extraParts = extras(entry);
|
||||||
|
const returned = returnedNote(entry);
|
||||||
const isBusy = busyId === entry.id;
|
const isBusy = busyId === entry.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -183,6 +195,18 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
|
|||||||
{extraParts.length > 0 && (
|
{extraParts.length > 0 && (
|
||||||
<p className="mt-0.5 truncate text-[11px] text-ink-400">{extraParts.join(' · ')}</p>
|
<p className="mt-0.5 truncate text-[11px] text-ink-400">{extraParts.join(' · ')}</p>
|
||||||
)}
|
)}
|
||||||
|
{returned && (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
entry.opportunity_deleted_at
|
||||||
|
? 'Diese Bewerbung war schon eine Opportunity. Die liegt im Papierkorb - wird sie dort zurückgeholt, verschwindet dieser Eintrag wieder.'
|
||||||
|
: 'Diese Bewerbung war schon eine Opportunity. Die ist endgültig gelöscht, deshalb steht die Bewerbung wieder hier.'
|
||||||
|
}
|
||||||
|
className="mt-1 inline-flex items-center gap-1 rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-amber-700"
|
||||||
|
>
|
||||||
|
{returned}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-4 py-3 text-sm text-ink-700">{entry.consultant || '—'}</td>
|
<td className="px-4 py-3 text-sm text-ink-700">{entry.consultant || '—'}</td>
|
||||||
|
|||||||
@@ -170,6 +170,11 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
|||||||
weiter bearbeiten und jederzeit in eine Opportunity übernehmen. Neue Projekte werden
|
weiter bearbeiten und jederzeit in eine Opportunity übernehmen. Neue Projekte werden
|
||||||
unter <em>Akquise</em> eingetragen.
|
unter <em>Akquise</em> eingetragen.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-ink-500">
|
||||||
|
Hier stehen auch Bewerbungen, deren <strong className="font-bold">Opportunity gelöscht</strong>{' '}
|
||||||
|
wurde - sonst wären sie nirgends mehr zu finden. Wird die Opportunity aus ihrem
|
||||||
|
Papierkorb zurückgeholt, verschwindet der Eintrag wieder von selbst.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<AcquisitionForm
|
<AcquisitionForm
|
||||||
|
|||||||
+9
-1
@@ -364,11 +364,19 @@ export interface Acquisition {
|
|||||||
next_step: string | null;
|
next_step: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
/** Gesetzt = uebernommen; der Eintrag verschwindet dann aus beiden Listen. */
|
/**
|
||||||
|
* Gesetzt = uebernommen. Der Eintrag verschwindet dann aus beiden Listen -
|
||||||
|
* es sei denn, die Opportunity ist inzwischen weg (`opportunity_gone`): dann
|
||||||
|
* faellt die Bewerbung ins Archiv zurueck.
|
||||||
|
*/
|
||||||
converted_at: string | null;
|
converted_at: string | null;
|
||||||
opportunity_id: number | null;
|
opportunity_id: number | null;
|
||||||
opportunity_title?: string | null;
|
opportunity_title?: string | null;
|
||||||
opportunity_stage?: OpportunityStage | null;
|
opportunity_stage?: OpportunityStage | null;
|
||||||
|
/** Gesetzt = die Opportunity liegt im Papierkorb (und ist zurueckholbar). */
|
||||||
|
opportunity_deleted_at?: string | null;
|
||||||
|
/** Die Opportunity ist im Papierkorb oder endgueltig geloescht. */
|
||||||
|
opportunity_gone?: boolean;
|
||||||
created_by: number | null;
|
created_by: number | null;
|
||||||
created_by_name?: string | null;
|
created_by_name?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user