feat(opportunities): Phasen des Vermittlungsprozesses, Kanban zuerst, Zahlen im Menue
Drei Dinge: - Die Phasen sind jetzt die des Vermittlungsprozesses: Neu, Beworben, Erstgespraech Berater, Kundenvorstellung, Interview Kunde, Zusage, Beauftragt, Absage. Offen ist alles bis einschliesslich Zusage - dort ist der Einsatz bestaetigt, die Beauftragung steht aber noch aus. migrateOpportunityStages() stellt bestehende Datenbanken um und setzt alle vorhandenen Opportunities auf "Neu": in welcher der neuen Phasen ein Projekt steckt, sagt der alte Trichter nicht (so mit dem Nutzer entschieden). Der Datenbankwert heisst 'Erstgespraech Berater' ohne Umlaut - Projektkonvention fuer Datenbankwerte. Angezeigt wird er ueber STAGE_LABELS; dazu gibt es STAGE_HINTS mit der Erlaeuterung je Phase als Titelhinweis an Auswahl, Badge und Spaltenkopf. - Kanban ist die erste Ansicht der Opportunities. Die Pipeline ist die Arbeitsansicht, Kachel und Liste sind zum Nachschauen. - Hinter "Archiv", beiden "Papierkorb"-Punkten und "Unpassend" steht die Anzahl, wie schon bei "Aus Mails". Die Zahlen kommen aus einem eigenen Endpunkt (/api/badges), weil sie alle ins Menue gehoeren und zusammen kommen sollen; Inhalte laedt weiter jede Ansicht selbst. Der Scanner liegt in einer eigenen Datenbank und kann fehlen - sein Zaehler ist dann null und die Leiste zeigt dort einfach keine Zahl. Der Aufruf haengt bewusst hinter den Daten und nicht im Promise.all: faellt er aus, bleibt das Menue ohne Zahlen statt die Ansicht mit einem Fehler. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1ac0e2d2c7
commit
42639c65fa
@@ -49,6 +49,7 @@ export async function initDatabase() {
|
||||
}
|
||||
|
||||
await migrateLeadKind();
|
||||
await migrateOpportunityStages();
|
||||
await migrateMailPool();
|
||||
await seedAcquisitionOptions();
|
||||
await dropProviderOptions();
|
||||
@@ -100,6 +101,48 @@ async function migrateLeadKind() {
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Einmalige Umstellung: aus dem allgemeinen Vertriebstrichter der Opportunities
|
||||
* (Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren) werden die
|
||||
* Phasen des Vermittlungsprozesses (Neu .. Beauftragt, Absage).
|
||||
*
|
||||
* Erkannt am Spaltentyp: steht 'Qualifizierung' noch im ENUM, ist die Umstellung
|
||||
* offen. Reihenfolge wie bei der Kontaktart - erst die Auswahl auf alt plus neu
|
||||
* erweitern, dann die Altwerte setzen, dann verengen. Direkt zu verengen wuerde
|
||||
* jeden Altwert zu '' machen.
|
||||
*
|
||||
* Alle vorhandenen Opportunities kommen auf 'Neu': welche Phase des neuen
|
||||
* Prozesses sie haben, sagt der alte Trichter nicht (so mit dem Nutzer
|
||||
* entschieden am 2026-08-18).
|
||||
*/
|
||||
async function migrateOpportunityStages() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'crm_opportunities'
|
||||
AND COLUMN_NAME = 'stage' LIMIT 1`
|
||||
);
|
||||
if (!rows[0] || !String(rows[0].COLUMN_TYPE).includes("'Qualifizierung'")) return;
|
||||
|
||||
await pool.query(
|
||||
`ALTER TABLE crm_opportunities MODIFY stage
|
||||
ENUM('Neu','Qualifizierung','Angebot','Verhandlung','Gewonnen','Verloren',
|
||||
'Beworben','Erstgespraech Berater','Kundenvorstellung','Interview Kunde',
|
||||
'Zusage','Beauftragt','Absage') NOT NULL DEFAULT 'Neu'`
|
||||
);
|
||||
const [result] = await pool.query(
|
||||
`UPDATE crm_opportunities SET stage = 'Neu'
|
||||
WHERE stage IN ('Qualifizierung','Angebot','Verhandlung','Gewonnen','Verloren')`
|
||||
);
|
||||
await pool.query(
|
||||
`ALTER TABLE crm_opportunities MODIFY stage
|
||||
ENUM('Neu','Beworben','Erstgespraech Berater','Kundenvorstellung','Interview Kunde','Zusage','Beauftragt','Absage') NOT NULL DEFAULT 'Neu'`
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[Opportunities] Phasen auf den Vermittlungsprozess umgestellt - ${result.affectedRows} Eintrag/Eintraege auf "Neu" gesetzt.`
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Einmalige Umstellung: die vom Postfachabgleich bereits angelegten Kontakte
|
||||
* (source = 'Mail') wandern in den Vorbereich "Aus Mails" (mail_pool = 1).
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import express from 'express';
|
||||
import { pool } from '../db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { Match } from '../scanner/models/index.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
/*
|
||||
* Zaehler fuer die Seitenleiste: Akquise-Archiv, Papierkorb der Opportunities,
|
||||
* Papierkorb der Mails und die unpassenden Scanner-Treffer.
|
||||
*
|
||||
* Ein Endpunkt statt vier: die Zahlen stehen alle im Menue und sollen zusammen
|
||||
* kommen. Die Ansichten selbst laden weiter ihre eigenen Daten - hier gibt es
|
||||
* ausschliesslich Zahlen, keine Inhalte.
|
||||
*
|
||||
* Der Scanner liegt in einer eigenen Datenbank und kann fehlen; sein Zaehler ist
|
||||
* dann null und die Leiste zeigt an der Stelle einfach keine Zahl. Ein Menue
|
||||
* ohne Zahl ist besser als ein Menue mit Fehlermeldung.
|
||||
*/
|
||||
|
||||
/** Grenze zwischen Eingang und Archiv der Akquise - wie in routes/acquisitions.js. */
|
||||
const ACQUISITION_ARCHIVE_AFTER_DAYS = 14;
|
||||
const ACQUISITION_DELETE_AFTER_DAYS = 90;
|
||||
|
||||
// GET /api/badges
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const [[counts]] = await pool.query(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM crm_acquisitions
|
||||
WHERE converted_at IS NULL
|
||||
AND found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||
AND found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS acquisition_archive,
|
||||
(SELECT COUNT(*) FROM crm_opportunities
|
||||
WHERE deleted_at IS NOT NULL) AS opportunity_trash,
|
||||
(SELECT COUNT(*) FROM crm_mail_messages
|
||||
WHERE crm_deleted_at IS NOT NULL AND crm_purged = 0) AS mail_trash`,
|
||||
[ACQUISITION_ARCHIVE_AFTER_DAYS, ACQUISITION_DELETE_AFTER_DAYS]
|
||||
);
|
||||
|
||||
let scannerRejected = null;
|
||||
try {
|
||||
scannerRejected = await Match.count({ where: { status: 'rejected' } });
|
||||
} catch {
|
||||
// Scanner-Datenbank nicht erreichbar - dann eben keine Zahl.
|
||||
}
|
||||
|
||||
res.json({
|
||||
acquisition_archive: Number(counts.acquisition_archive),
|
||||
opportunity_trash: Number(counts.opportunity_trash),
|
||||
mail_trash: Number(counts.mail_trash),
|
||||
scanner_rejected: scannerRejected,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -5,13 +5,17 @@ import { requireAuth } from '../middleware/auth.js';
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// Phasen des Vermittlungsprozesses - dieselbe Reihenfolge wie im Frontend
|
||||
// (OPPORTUNITY_STAGES in src/types.ts) und in der Spalte crm_opportunities.stage.
|
||||
const STAGES = new Set([
|
||||
'Neu',
|
||||
'Qualifizierung',
|
||||
'Angebot',
|
||||
'Verhandlung',
|
||||
'Gewonnen',
|
||||
'Verloren',
|
||||
'Beworben',
|
||||
'Erstgespraech Berater',
|
||||
'Kundenvorstellung',
|
||||
'Interview Kunde',
|
||||
'Zusage',
|
||||
'Beauftragt',
|
||||
'Absage',
|
||||
]);
|
||||
|
||||
const TEXT_FIELDS = ['title', 'company', 'description', 'last_interaction_note', 'next_step', 'notes'];
|
||||
|
||||
@@ -114,7 +114,10 @@ CREATE TABLE IF NOT EXISTS crm_opportunities (
|
||||
title VARCHAR(190) NOT NULL,
|
||||
company VARCHAR(190) NULL,
|
||||
description TEXT NULL,
|
||||
stage ENUM('Neu','Qualifizierung','Angebot','Verhandlung','Gewonnen','Verloren')
|
||||
-- Phasen des Vermittlungsprozesses. Bis zum 18.08.2026 stand hier ein
|
||||
-- allgemeiner Vertriebstrichter (Neu .. Verloren); die Umstellung bestehender
|
||||
-- Datenbanken macht migrateOpportunityStages() in db.js.
|
||||
stage ENUM('Neu','Beworben','Erstgespraech Berater','Kundenvorstellung','Interview Kunde','Zusage','Beauftragt','Absage')
|
||||
NOT NULL DEFAULT 'Neu',
|
||||
probability TINYINT UNSIGNED NOT NULL DEFAULT 10, -- 0-100 %
|
||||
daily_rate DECIMAL(10,2) NULL, -- Tagessatz in Euro
|
||||
|
||||
@@ -9,6 +9,7 @@ import connectionRoutes from './routes/connections.js';
|
||||
import eventRoutes from './routes/events.js';
|
||||
import opportunityRoutes from './routes/opportunities.js';
|
||||
import acquisitionRoutes from './routes/acquisitions.js';
|
||||
import badgeRoutes from './routes/badges.js';
|
||||
import scannerRoutes from './routes/scanner.js';
|
||||
import mailRoutes from './routes/mail.js';
|
||||
import { requireAuth } from './middleware/auth.js';
|
||||
@@ -50,6 +51,9 @@ app.use('/api/events', eventRoutes);
|
||||
app.use('/api/opportunities', opportunityRoutes);
|
||||
app.use('/api/acquisitions', acquisitionRoutes);
|
||||
|
||||
// Zahlen fuer die Seitenleiste - eine Anfrage fuer alle Zaehler.
|
||||
app.use('/api/badges', badgeRoutes);
|
||||
|
||||
// Projekt Scanner. Komplett hinter der CRM-Anmeldung - der urspruengliche
|
||||
// HTTP-Basic-Auth-Zugang des Scanners entfaellt damit.
|
||||
app.use('/api/scanner', requireAuth, scannerRoutes);
|
||||
|
||||
Reference in New Issue
Block a user