From b9d6544b30bd45f12fb344603f84540b9d3e22b6 Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Thu, 1 Oct 2026 11:29:00 +0200 Subject: [PATCH] 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 --- docs/02-datenmodell.md | 8 +++++ docs/07-anleitung-benutzer.md | 4 +++ server/src/routes/opportunities.js | 9 ++++++ server/src/schema.sql | 10 ++++++ .../opportunities/OpportunityModal.tsx | 31 ++++++++++++++----- src/components/ui/Modal.tsx | 5 +-- src/types.ts | 4 +++ 7 files changed, 62 insertions(+), 9 deletions(-) diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index 0e99f47..812ae70 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -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 diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index 2e47dfa..7697011 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -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 diff --git a/server/src/routes/opportunities.js b/server/src/routes/opportunities.js index cae4ff1..f912644 100644 --- a/server/src/routes/opportunities.js +++ b/server/src/routes/opportunities.js @@ -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 diff --git a/server/src/schema.sql b/server/src/schema.sql index ef97515..958bf1d 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -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. diff --git a/src/components/opportunities/OpportunityModal.tsx b/src/components/opportunities/OpportunityModal.tsx index 4a379d7..595a797 100644 --- a/src/components/opportunities/OpportunityModal.tsx +++ b/src/components/opportunities/OpportunityModal.tsx @@ -281,18 +281,35 @@ export const OpportunityModal: React.FC = ({ } }; + // 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 ( <> +

+ Angelegt von {opportunity.created_by_name ?? 'unbekannt'} + {opportunity.created_at && + ` am ${new Date(opportunity.created_at).toLocaleDateString('de-DE')}`} +

+ {movedAt && ( +

+ 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]}“ +

+ )} + + ) : ( + 'Vertriebschance mit Projektdaten und Ansprechpartnern' + ) } onClose={onClose} size="xl" diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 922714d..410ff8a 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -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 = ({

{title}

- {subtitle &&

{subtitle}

} + {subtitle &&
{subtitle}
}