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
|
varchar next_step
|
||||||
int owner_id FK
|
int owner_id FK
|
||||||
datetime won_at "Wechsel nach Beauftragt"
|
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"
|
datetime deleted_at "Papierkorb; nach 90 Tagen weg"
|
||||||
}
|
}
|
||||||
crm_opportunity_contacts {
|
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
|
das gesichert wird, was verschwindet. `crm_opportunities.won_at` hält den
|
||||||
Zeitpunkt des Wechsels nach *Beauftragt* - die Phase allein sagt nur, wie es
|
Zeitpunkt des Wechsels nach *Beauftragt* - die Phase allein sagt nur, wie es
|
||||||
jetzt steht, nicht wann es passiert ist.
|
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`
|
- **Gelöschte Opportunities bleiben stehen.** `crm_opportunities.deleted_at`
|
||||||
setzt sie in den Papierkorb; alle Abfragen blenden sie aus. An einer
|
setzt sie in den Papierkorb; alle Abfragen blenden sie aus. An einer
|
||||||
Opportunity hängen Ansprechpartner, Verlauf und Mailzuordnungen - ein Fehlklick
|
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
|
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.
|
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
|
Beim Anlegen einer neuen Opportunity steht **Zuständig** schon auf dem
|
||||||
|
|||||||
@@ -91,11 +91,13 @@ const SELECT_OPPORTUNITIES = `
|
|||||||
owner.color AS owner_color,
|
owner.color AS owner_color,
|
||||||
owner.avatar AS owner_avatar,
|
owner.avatar AS owner_avatar,
|
||||||
deleter.name AS deleted_by_name,
|
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
|
(src.id IS NOT NULL AND src.deleted_at IS NULL) AS acquisition_available
|
||||||
FROM crm_opportunities o
|
FROM crm_opportunities o
|
||||||
LEFT JOIN crm_users creator ON creator.id = o.created_by
|
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 owner ON owner.id = o.owner_id
|
||||||
LEFT JOIN crm_users deleter ON deleter.id = o.deleted_by
|
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
|
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 = ?');
|
fields.push('stage = ?');
|
||||||
values.push(req.body.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'
|
* Zeitpunkt des Gewinns mitschreiben. Der erste Wechsel nach 'Beauftragt'
|
||||||
* zaehlt; ein zweites Speichern derselben Phase darf das Datum nicht nach
|
* zaehlt; ein zweites Speichern derselben Phase darf das Datum nicht nach
|
||||||
|
|||||||
@@ -512,6 +512,16 @@ UPDATE crm_opportunities o
|
|||||||
SET o.link = a.link
|
SET o.link = a.link
|
||||||
WHERE o.link IS NULL AND a.link IS NOT NULL;
|
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
|
* Auswahllisten der Akquise. "Von", "Kanal" und "Berater" sind Dropdowns, deren
|
||||||
* Inhalte in den Einstellungen gepflegt werden.
|
* 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
title={opportunity ? opportunity.title : 'Neue Opportunity'}
|
title={opportunity ? opportunity.title : 'Neue Opportunity'}
|
||||||
subtitle={
|
subtitle={
|
||||||
opportunity
|
opportunity ? (
|
||||||
? `Angelegt von ${opportunity.created_by_name ?? 'unbekannt'}${
|
<>
|
||||||
opportunity.created_at
|
<p className="truncate">
|
||||||
? ` am ${new Date(opportunity.created_at).toLocaleDateString('de-DE')}`
|
Angelegt von {opportunity.created_by_name ?? 'unbekannt'}
|
||||||
: ''
|
{opportunity.created_at &&
|
||||||
}`
|
` am ${new Date(opportunity.created_at).toLocaleDateString('de-DE')}`}
|
||||||
: 'Vertriebschance mit Projektdaten und Ansprechpartnern'
|
</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}
|
onClose={onClose}
|
||||||
size="xl"
|
size="xl"
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import React, { useEffect, useRef } from 'react';
|
|||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
/** Text oder mehrere Zeilen; jede Zeile wird einzeln abgeschnitten. */
|
||||||
|
subtitle?: React.ReactNode;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
footer?: 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="flex shrink-0 items-start justify-between gap-4 border-b border-ink-100 px-6 py-5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="truncate text-xl text-ink-900">{title}</h2>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|||||||
@@ -346,6 +346,9 @@ export interface Opportunity {
|
|||||||
created_by: number | null;
|
created_by: number | null;
|
||||||
/** Zeitpunkt des Wechsels nach "Beauftragt" - Grundlage der Gewinn-Auswertung. */
|
/** Zeitpunkt des Wechsels nach "Beauftragt" - Grundlage der Gewinn-Auswertung. */
|
||||||
won_at: string | null;
|
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. */
|
/** Gesetzt = liegt im Papierkorb; nach 90 Tagen wird sie dort geloescht. */
|
||||||
deleted_at: string | null;
|
deleted_at: string | null;
|
||||||
deleted_by: number | null;
|
deleted_by: number | null;
|
||||||
@@ -357,6 +360,7 @@ export interface Opportunity {
|
|||||||
owner_color?: string | null;
|
owner_color?: string | null;
|
||||||
owner_avatar?: string | null;
|
owner_avatar?: string | null;
|
||||||
deleted_by_name?: string | null;
|
deleted_by_name?: string | null;
|
||||||
|
stage_changed_by_name?: string | null;
|
||||||
contacts: OpportunityContact[];
|
contacts: OpportunityContact[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user