From 6724d0ab65aed362d0c498e153442d8cd08c25b0 Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Thu, 17 Sep 2026 13:31:59 +0200 Subject: [PATCH] feat(opportunities): Opportunity fuer andere Berater kopieren Kopier-Symbol auf Kachel, Pipeline-Karte und Listenzeile sowie "Kopieren" im Dialog. Ein Dialog fragt nach einem oder mehreren Beratern (aus der Beraterliste der Akquise, ohne den eigenen) und der Phase der Kopie (Standard: Neu). Neuer Endpoint POST /api/opportunities/:id/copy legt je Berater eine Kopie mit Projektdaten, Ansprechpartnern, Zustaendigem und acquisition_id an; Interaktion und naechster Schritt bleiben beim Original. Co-Authored-By: Claude Opus 5 (1M context) --- docs/07-anleitung-benutzer.md | 19 ++ docs/09-quellcode.md | 3 +- server/src/routes/opportunities.js | 100 ++++++++++ .../opportunities/Opportunities.tsx | 20 +- .../opportunities/OpportunityCopyDialog.tsx | 180 ++++++++++++++++++ .../opportunities/OpportunityGrid.tsx | 16 ++ .../opportunities/OpportunityList.tsx | 18 +- .../opportunities/OpportunityModal.tsx | 17 ++ .../opportunities/OpportunityPipeline.tsx | 16 ++ src/lib/api.ts | 6 + 10 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 src/components/opportunities/OpportunityCopyDialog.tsx diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index 44a2582..dbfea12 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -367,6 +367,25 @@ Spalte steht ihr Volumen; eine Karte wird per Maus in die nächste Phase gezogen ![Pipeline-Ansicht mit den Phasen von Neu bis Absage](bilder/opportunities-pipeline.png) +### Für einen anderen Berater kopieren + +Eine Opportunity läuft immer für **einen** Berater. Passt dasselbe Projekt auch +für Kollegen, legt das **Kopier-Symbol** (auf jeder Kachel, Karte und +Listenzeile, dazu *Kopieren* unten im Dialog) je Berater eine eigene Kopie an: + +1. Kopier-Symbol anklicken. +2. Einen oder mehrere Berater ankreuzen - der Berater der Opportunity selbst + steht nicht zur Wahl. Die Liste ist dieselbe wie in der Akquise + (*Einstellungen → Akquise*). +3. Phase der Kopie wählen, vorgegeben ist **Neu**. +4. **Kopie anlegen**. + +Mitkopiert werden Titel, Firma, Beschreibung, Tagessatz, Laufzeit, Projektpreis, +Zeitraum, Tags, Notizen, Ansprechpartner, Zuständiger und die Herkunft aus der +Akquise - *Zurück in die Akquise* nimmt die Kopien also mit. Nicht mitkopiert +werden letzte Interaktion und nächster Schritt: die gehören zum Verlauf des +Originals. Ungespeicherte Änderungen im Dialog kopiert das CRM nicht mit. + ### Die Phasen Sie folgen dem Vermittlungsprozess. Der Mauszeiger auf einer Phase zeigt dieselbe diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index 911a0f5..dbecfa6 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -99,7 +99,7 @@ Alle unter `router.use(requireAuth)`. | `leads.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id`; Vorbereich: `GET /mail-pool/sources`, `POST /mail-pool/promote`, `POST /:id/promote` | jeder Angemeldete | | `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete | | `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin | -| `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /:id/acquisition`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete. `/:id/acquisition` nimmt eine Übernahme zurück: alle lebenden Opportunities mit derselben `acquisition_id` in den Papierkorb, beim Eintrag `converted_at`/`opportunity_id` leeren (Funddatum bleibt), in einer Transaktion. `/restore` hängt den Eintrag wieder an die Opportunity, wenn er frei ist | +| `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /:id/acquisition`, `POST /:id/copy`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete. `/:id/acquisition` nimmt eine Übernahme zurück: alle lebenden Opportunities mit derselben `acquisition_id` in den Papierkorb, beim Eintrag `converted_at`/`opportunity_id` leeren (Funddatum bleibt), in einer Transaktion. `/restore` hängt den Eintrag wieder an die Opportunity, wenn er frei ist. `/:id/copy` (`{ consultants, stage }`) legt je Berater eine Kopie an - Projektdaten, Ansprechpartner, Zuständiger und `acquisition_id` kommen mit, Interaktion und nächster Schritt nicht; jeder Angemeldete | | `acquisitions.js` | `GET /?view=eingang\|archiv\|papierkorb\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `DELETE /:id/permanent`, `POST /trash/empty`, `POST /:id/opportunity`, `POST /:id/reopen`, `POST /:id/restore`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern, löschen und zurückholen nur Ersteller oder Admin; Auswahllisten und Leeren jeder Angemeldete. Zwei verschiedene Rückwege, nicht zu verwechseln: `/reopen` holt aus dem **Archiv** in den Eingang (setzt `found_at` auf heute und leert `converted_at`/`opportunity_id` - sonst hält die Übernahme den Eintrag im Archiv fest), `/restore` holt aus dem **Papierkorb** zurück (leert nur `deleted_at`/`deleted_by`, das Funddatum bleibt). Beide antworten 409, wenn der Eintrag im falschen Zustand ist | | `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv und dessen Papierkorb, Papierkorb der Opportunities und der Mails, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt | | `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IS_OPEN`, `IN_ARCHIVE`, `NOT_DUE`, `OPPORTUNITY_GONE`, `NOT_DELETED`, `IN_TRASH`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen. Dazu `splitConsultants` / `joinConsultants` / `normalizeConsultants` für das Feld *Berater*, das mehrere Namen kommagetrennt hält (Gegenstück im Frontend: `src/types.ts`) | @@ -315,6 +315,7 @@ Warum genau diese Konstanten festgelegt sind, steht in | Datei | Inhalt | | --- | --- | +| `components/opportunities/OpportunityCopyDialog.tsx` | Kopie für andere Berater: Auswahl aus der Beraterliste der Akquise (ohne den eigenen), Phase der Kopie | | `components/opportunities/OpportunityTrash.tsx` | Papierkorb der Opportunities: wiederherstellen, leeren, Restlaufzeit je Eintrag | | `components/opportunities/opportunityUi.tsx` | `STAGE_LABELS` (Anzeige, mit Umlaut), `STAGE_HINTS` (Erläuterung als Titelhinweis), `STAGE_STYLES`, `STAGE_COLORS`, `StageBadge` | | `components/dashboard/Dashboard.tsx` | Startansicht: Zeitraumwahl, vier Kennzahlen, Verlauf, Berater, Termine, „Beim Kunden". `buildAxis()` erzeugt auch leere Abschnitte | diff --git a/server/src/routes/opportunities.js b/server/src/routes/opportunities.js index dc1dd76..a0bd141 100644 --- a/server/src/routes/opportunities.js +++ b/server/src/routes/opportunities.js @@ -701,4 +701,104 @@ router.post('/:id/acquisition', async (req, res, next) => { } }); +/* + * POST /api/opportunities/:id/copy - fuer andere Berater kopieren + * + * Eine Opportunity laeuft fuer genau einen Berater. Passt dasselbe Projekt auch + * fuer einen Kollegen, entsteht je Berater eine Kopie mit eigener Phase - wie + * bei der Uebernahme aus der Akquise, nur nachtraeglich. + * + * Kopiert wird, was das Projekt beschreibt: Titel, Firma, Beschreibung, Zahlen, + * Zeitraum, Tags, Notizen, Ansprechpartner, Zustaendiger und die Herkunft aus + * der Akquise (damit "Zurueck in die Akquise" auch die Kopien findet). Nicht + * kopiert wird, was zum Verlauf des Originals gehoert: letzte Interaktion, + * naechster Schritt, Gewinndatum. Die Phase waehlt der Aufrufer, sonst "Neu". + * + * Body: { consultants: string[], stage?: string } + */ +router.post('/:id/copy', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Opportunity-ID.' }); + + const raw = Array.isArray(req.body?.consultants) ? req.body.consultants : []; + // Getrimmt und ohne Dubletten - beim Vergleich ohne Gross-/Kleinschreibung. + const seen = new Set(); + const consultants = []; + for (const entry of raw) { + const name = emptyToNull(entry); + if (name === null || seen.has(name.toLowerCase())) continue; + if (name.length > CONSULTANT_MAX_LENGTH) { + return res + .status(400) + .json({ error: `Der Berater ist zu lang (max. ${CONSULTANT_MAX_LENGTH} Zeichen).` }); + } + seen.add(name.toLowerCase()); + consultants.push(name); + } + if (consultants.length === 0) { + return res.status(400).json({ error: 'Mindestens ein Berater ist erforderlich.' }); + } + if (consultants.length > 20) { + return res.status(400).json({ error: 'Höchstens 20 Berater auf einmal.' }); + } + + const stage = emptyToNull(req.body?.stage) ?? 'Neu'; + if (!STAGES.has(stage)) return res.status(400).json({ error: 'Ungültige Phase.' }); + + const conn = await pool.getConnection(); + try { + const [rows] = await conn.query('SELECT * FROM crm_opportunities WHERE id = ? LIMIT 1', [id]); + const source = rows[0]; + if (!source) return res.status(404).json({ error: 'Opportunity nicht gefunden.' }); + if (source.deleted_at !== null) { + return res.status(409).json({ error: 'Diese Opportunity liegt im Papierkorb.' }); + } + + const [contactRows] = await conn.query( + 'SELECT lead_id, role FROM crm_opportunity_contacts WHERE opportunity_id = ?', + [id] + ); + + await conn.beginTransaction(); + const createdIds = []; + for (const consultant of consultants) { + const [created] = await conn.query( + `INSERT INTO crm_opportunities + (title, company, consultant, acquisition_id, description, stage, won_at, probability, + daily_rate, duration_days, fixed_price, start_date, end_date, tags, notes, + owner_id, created_by) + VALUES (?, ?, ?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + source.title, + source.company, + consultant, + source.acquisition_id, + source.description, + stage, + source.probability, + source.daily_rate, + source.duration_days, + source.fixed_price, + source.start_date, + source.end_date, + source.tags ?? '[]', + source.notes, + source.owner_id, + req.user.id, + ] + ); + await replaceContacts(conn, created.insertId, contactRows); + createdIds.push(created.insertId); + } + await conn.commit(); + + res.status(201).json({ ok: true, opportunity_ids: createdIds }); + } catch (err) { + await conn.rollback().catch(() => {}); + next(err); + } finally { + conn.release(); + } +}); + export default router; diff --git a/src/components/opportunities/Opportunities.tsx b/src/components/opportunities/Opportunities.tsx index f3c7588..81ef903 100644 --- a/src/components/opportunities/Opportunities.tsx +++ b/src/components/opportunities/Opportunities.tsx @@ -9,6 +9,7 @@ import { } from './OpportunityList'; import { OpportunityPipeline } from './OpportunityPipeline'; import { OpportunityModal } from './OpportunityModal'; +import { OpportunityCopyDialog } from './OpportunityCopyDialog'; import { STAGE_LABELS } from './opportunityUi'; import { ConfirmDialog } from '../ui/Modal'; import { useToast } from '../Toast'; @@ -113,6 +114,8 @@ export const Opportunities: React.FC = ({ const [isModalOpen, setIsModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [toDelete, setToDelete] = useState(null); + // Fuer welche Opportunity gerade Kopien fuer andere Berater angelegt werden. + const [toCopy, setToCopy] = useState(null); const filtered = useMemo(() => { const terms = searchTerm @@ -388,7 +391,7 @@ export const Opportunities: React.FC = ({ )} ) : viewMode === 'grid' ? ( - + ) : viewMode === 'list' ? ( = ({ onSort={handleSort} onEdit={openEdit} onDelete={setToDelete} + onCopy={setToCopy} /> ) : ( = ({ onEdit={openEdit} onStageChange={changeStage} onDelete={setToDelete} + onCopy={setToCopy} /> )} @@ -413,6 +418,19 @@ export const Opportunities: React.FC = ({ users={users} onClose={() => setIsModalOpen(false)} onSaved={reload} + onCopy={(opportunity) => { + // Der Bearbeiten-Dialog schliesst, sonst laegen zwei Dialoge uebereinander. + setIsModalOpen(false); + setToCopy(opportunity); + }} + /> + )} + + {toCopy && ( + setToCopy(null)} + onCopied={reload} /> )} diff --git a/src/components/opportunities/OpportunityCopyDialog.tsx b/src/components/opportunities/OpportunityCopyDialog.tsx new file mode 100644 index 0000000..b33df5f --- /dev/null +++ b/src/components/opportunities/OpportunityCopyDialog.tsx @@ -0,0 +1,180 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Modal } from '../ui/Modal'; +import { useToast } from '../Toast'; +import { STAGE_LABELS } from './opportunityUi'; +import { acquisitionOptionsApi, opportunitiesApi } from '../../lib/api'; +import { OPPORTUNITY_STAGES, type Opportunity, type OpportunityStage } from '../../types'; + +interface OpportunityCopyDialogProps { + opportunity: Opportunity; + onClose: () => void; + onCopied: () => void; +} + +/** + * Kopiert eine Opportunity fuer einen oder mehrere andere Berater. + * + * Eine Opportunity laeuft fuer genau einen Berater; passt das Projekt auch fuer + * Kollegen, bekommt jeder eine eigene Kopie mit eigener Phase. Die Berater + * kommen aus derselben Auswahlliste wie in der Akquise (Einstellungen -> + * Akquise). Der Berater des Originals steht nicht zur Wahl - fuer ihn gibt es + * die Opportunity schon. + * + * Die Phase startet bei "Neu": der Kollege hat den Vorgang noch nicht + * durchlaufen. Wer es anders braucht, stellt sie hier um. + */ +export const OpportunityCopyDialog: React.FC = ({ + opportunity, + onClose, + onCopied, +}) => { + const { addToast } = useToast(); + const [options, setOptions] = useState(null); + const [selected, setSelected] = useState([]); + const [stage, setStage] = useState('Neu'); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + acquisitionOptionsApi + .list() + .then((list) => { + if (!cancelled) setOptions(list.consultant.map((option) => option.value)); + }) + .catch((err) => { + if (cancelled) return; + setOptions([]); + addToast(err instanceof Error ? err.message : 'Berater konnten nicht geladen werden.', 'error'); + }); + return () => { + cancelled = true; + }; + }, [addToast]); + + const available = useMemo(() => { + const own = (opportunity.consultant ?? '').trim().toLowerCase(); + return (options ?? []).filter((name) => name.trim().toLowerCase() !== own); + }, [options, opportunity.consultant]); + + const toggle = (name: string) => + setSelected((prev) => + prev.includes(name) ? prev.filter((entry) => entry !== name) : [...prev, name] + ); + + const submit = async () => { + if (selected.length === 0) return; + setIsSaving(true); + try { + const result = await opportunitiesApi.copy(opportunity.id, { consultants: selected, stage }); + addToast( + result.opportunity_ids.length === 1 + ? `„${opportunity.title}" für ${selected[0]} angelegt.` + : `„${opportunity.title}" für ${result.opportunity_ids.length} Berater angelegt.`, + 'success' + ); + onCopied(); + onClose(); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Kopieren fehlgeschlagen.', 'error'); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + + } + > +
+

+ Für jeden gewählten Berater entsteht eine eigene Opportunity mit denselben + Projektdaten, Ansprechpartnern und Notizen - aber eigener Phase. +

+ +
+

Berater

+ {options === null ? ( +
+ +
+ ) : available.length === 0 ? ( +

+ Keine weiteren Berater in der Liste. Gepflegt wird sie unter Einstellungen → Akquise. +

+ ) : ( +
+ {available.map((name) => { + const checked = selected.includes(name); + return ( + + ); + })} +
+ )} +
+ +
+ + +
+
+
+ ); +}; diff --git a/src/components/opportunities/OpportunityGrid.tsx b/src/components/opportunities/OpportunityGrid.tsx index 7268547..ce60685 100644 --- a/src/components/opportunities/OpportunityGrid.tsx +++ b/src/components/opportunities/OpportunityGrid.tsx @@ -21,6 +21,8 @@ interface OpportunityGridProps { opportunities: Opportunity[]; onEdit: (opportunity: Opportunity) => void; onDelete: (opportunity: Opportunity) => void; + /** Kopie fuer einen anderen Berater anlegen. */ + onCopy: (opportunity: Opportunity) => void; } const Metric: React.FC<{ label: string; value: string; className?: string }> = ({ @@ -38,6 +40,7 @@ export const OpportunityGrid: React.FC = ({ opportunities, onEdit, onDelete, + onCopy, }) => (
{opportunities.map((opportunity) => { @@ -76,6 +79,19 @@ export const OpportunityGrid: React.FC = ({
{/* Sichtbar, nicht erst beim Ueberfahren: ein Knopf, den man nur findet, wenn man ihn schon kennt, ist keiner. */} + + )} {/* Nur bei Opportunities aus der Akquise, deren Eintrag noch da ist. */} {opportunity?.acquisition_available && (