feat(opportunities): letzten Phasenwechsel mit Benutzer und Zeit anzeigen

Neue Spalten stage_changed_at/stage_changed_by, gesetzt nur bei echtem
Phasenwechsel per PATCH. Der Dialog zeigt unter "Angelegt von" die Zeile
"Verschoben von ... am ... um ... in ...".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-10-01 11:29:00 +02:00
co-authored by Claude Opus 5.5
parent 5ba1ee1e0b
commit b9d6544b30
7 changed files with 62 additions and 9 deletions
+8
View File
@@ -78,6 +78,8 @@ erDiagram
varchar next_step
int owner_id FK
datetime won_at "Wechsel nach Beauftragt"
datetime stage_changed_at "letzter Phasenwechsel"
int stage_changed_by "wer verschoben hat, ohne FK"
datetime deleted_at "Papierkorb; nach 90 Tagen weg"
}
crm_opportunity_contacts {
@@ -170,6 +172,12 @@ Acht Eigenheiten, die man kennen muss:
das gesichert wird, was verschwindet. `crm_opportunities.won_at` hält den
Zeitpunkt des Wechsels nach *Beauftragt* - die Phase allein sagt nur, wie es
jetzt steht, nicht wann es passiert ist.
- **Letzter Phasenwechsel.** `crm_opportunities.stage_changed_at` (UTC) und
`stage_changed_by` setzt `PATCH /api/opportunities/:id` nur, wenn sich die
Phase wirklich ändert - Anlegen und Kopieren zählen nicht. Der Dialog zeigt
daraus unter „Angelegt von" die Zeile „Verschoben von … am … um … in …".
Es ist nur der letzte Wechsel, kein Verlauf; Opportunities von vor dem
01.10.2026 haben keinen Eintrag, bis sie das nächste Mal verschoben werden.
- **Gelöschte Opportunities bleiben stehen.** `crm_opportunities.deleted_at`
setzt sie in den Papierkorb; alle Abfragen blenden sie aus. An einer
Opportunity hängen Ansprechpartner, Verlauf und Mailzuordnungen - ein Fehlklick
+4
View File
@@ -413,6 +413,10 @@ wechselt man die Darstellung:
Die **Pipeline** zeigt dieselben Opportunities nach Phasen sortiert. Über jeder
Spalte steht ihr Volumen; eine Karte wird per Maus in die nächste Phase gezogen.
Wer eine Opportunity öffnet, sieht oben unter **Angelegt von** auch, wer sie
zuletzt in welche Phase verschoben hat und wann (Datum und Uhrzeit). Die Zeile
erscheint erst nach dem ersten Verschieben.
![Pipeline-Ansicht mit den Phasen von Neu bis Absage](bilder/opportunities-pipeline.png)
Beim Anlegen einer neuen Opportunity steht **Zuständig** schon auf dem
+9
View File
@@ -91,11 +91,13 @@ const SELECT_OPPORTUNITIES = `
owner.color AS owner_color,
owner.avatar AS owner_avatar,
deleter.name AS deleted_by_name,
mover.name AS stage_changed_by_name,
(src.id IS NOT NULL AND src.deleted_at IS NULL) AS acquisition_available
FROM crm_opportunities o
LEFT JOIN crm_users creator ON creator.id = o.created_by
LEFT JOIN crm_users owner ON owner.id = o.owner_id
LEFT JOIN crm_users deleter ON deleter.id = o.deleted_by
LEFT JOIN crm_users mover ON mover.id = o.stage_changed_by
LEFT JOIN crm_acquisitions src ON src.id = o.acquisition_id
`;
@@ -434,6 +436,13 @@ router.patch('/:id', async (req, res, next) => {
fields.push('stage = ?');
values.push(req.body.stage);
// Wer hat wann verschoben - nur bei echtem Wechsel, sonst wuerde jedes
// Speichern im Dialog den Zeitpunkt nach vorn schieben.
if (req.body.stage !== existing.stage) {
fields.push('stage_changed_at = UTC_TIMESTAMP()', 'stage_changed_by = ?');
values.push(req.user.id);
}
/*
* Zeitpunkt des Gewinns mitschreiben. Der erste Wechsel nach 'Beauftragt'
* zaehlt; ein zweites Speichern derselben Phase darf das Datum nicht nach
+10
View File
@@ -512,6 +512,16 @@ UPDATE crm_opportunities o
SET o.link = a.link
WHERE o.link IS NULL AND a.link IS NOT NULL;
/*
* Letzter Phasenwechsel: wann und von wem. Der Dialog zeigt es unter "Angelegt
* von". Gesetzt nur, wenn PATCH die Phase wirklich aendert - Anlegen und
* Kopieren sind kein Verschieben. Alte Opportunities bleiben leer, eine
* Rueckschreibung gibt es nicht, weil niemand weiss, wer es war.
* Ohne Fremdschluessel wie `deleted_by`.
*/
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS stage_changed_at DATETIME NULL AFTER won_at;
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS stage_changed_by INT UNSIGNED NULL AFTER stage_changed_at;
/*
* Auswahllisten der Akquise. "Von", "Kanal" und "Berater" sind Dropdowns, deren
* Inhalte in den Einstellungen gepflegt werden.
@@ -281,18 +281,35 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
}
};
// MariaDB-DATETIME steht in UTC; ohne das angehaengte Z liest der Browser Ortszeit.
const movedAt = opportunity?.stage_changed_at
? new Date(`${opportunity.stage_changed_at.replace(' ', 'T')}Z`)
: null;
return (
<>
<Modal
title={opportunity ? opportunity.title : 'Neue Opportunity'}
subtitle={
opportunity
? `Angelegt von ${opportunity.created_by_name ?? 'unbekannt'}${
opportunity.created_at
? ` am ${new Date(opportunity.created_at).toLocaleDateString('de-DE')}`
: ''
}`
: 'Vertriebschance mit Projektdaten und Ansprechpartnern'
opportunity ? (
<>
<p className="truncate">
Angelegt von {opportunity.created_by_name ?? 'unbekannt'}
{opportunity.created_at &&
` am ${new Date(opportunity.created_at).toLocaleDateString('de-DE')}`}
</p>
{movedAt && (
<p className="truncate">
Verschoben von {opportunity.stage_changed_by_name ?? 'unbekannt'} am{' '}
{movedAt.toLocaleDateString('de-DE')} um{' '}
{movedAt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} in „
{STAGE_LABELS[opportunity.stage]}“
</p>
)}
</>
) : (
'Vertriebschance mit Projektdaten und Ansprechpartnern'
)
}
onClose={onClose}
size="xl"
+3 -2
View File
@@ -2,7 +2,8 @@ import React, { useEffect, useRef } from 'react';
interface ModalProps {
title: string;
subtitle?: string;
/** Text oder mehrere Zeilen; jede Zeile wird einzeln abgeschnitten. */
subtitle?: React.ReactNode;
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
@@ -90,7 +91,7 @@ export const Modal: React.FC<ModalProps> = ({
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-ink-100 px-6 py-5">
<div className="min-w-0">
<h2 className="truncate text-xl text-ink-900">{title}</h2>
{subtitle && <p className="mt-0.5 truncate text-xs font-semibold text-ink-400">{subtitle}</p>}
{subtitle && <div className="mt-0.5 truncate text-xs font-semibold text-ink-400">{subtitle}</div>}
</div>
<button
onClick={onClose}
+4
View File
@@ -346,6 +346,9 @@ export interface Opportunity {
created_by: number | null;
/** Zeitpunkt des Wechsels nach "Beauftragt" - Grundlage der Gewinn-Auswertung. */
won_at: string | null;
/** Letzter Phasenwechsel (UTC) und wer ihn gemacht hat; leer, solange nie verschoben. */
stage_changed_at?: string | null;
stage_changed_by?: number | null;
/** Gesetzt = liegt im Papierkorb; nach 90 Tagen wird sie dort geloescht. */
deleted_at: string | null;
deleted_by: number | null;
@@ -357,6 +360,7 @@ export interface Opportunity {
owner_color?: string | null;
owner_avatar?: string | null;
deleted_by_name?: string | null;
stage_changed_by_name?: string | null;
contacts: OpportunityContact[];
}