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:
co-authored by
Claude Opus 5.5
parent
5ba1ee1e0b
commit
b9d6544b30
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
Beim Anlegen einer neuen Opportunity steht **Zuständig** schon auf dem
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user