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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
33dedaedfb
commit
6724d0ab65
@@ -367,6 +367,25 @@ Spalte steht ihr Volumen; eine Karte wird per Maus in die nächste Phase gezogen
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### 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
|
### Die Phasen
|
||||||
|
|
||||||
Sie folgen dem Vermittlungsprozess. Der Mauszeiger auf einer Phase zeigt dieselbe
|
Sie folgen dem Vermittlungsprozess. Der Mauszeiger auf einer Phase zeigt dieselbe
|
||||||
|
|||||||
@@ -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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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`) |
|
| `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 |
|
| 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/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/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 |
|
| `components/dashboard/Dashboard.tsx` | Startansicht: Zeitraumwahl, vier Kennzahlen, Verlauf, Berater, Termine, „Beim Kunden". `buildAxis()` erzeugt auch leere Abschnitte |
|
||||||
|
|||||||
@@ -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;
|
export default router;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from './OpportunityList';
|
} from './OpportunityList';
|
||||||
import { OpportunityPipeline } from './OpportunityPipeline';
|
import { OpportunityPipeline } from './OpportunityPipeline';
|
||||||
import { OpportunityModal } from './OpportunityModal';
|
import { OpportunityModal } from './OpportunityModal';
|
||||||
|
import { OpportunityCopyDialog } from './OpportunityCopyDialog';
|
||||||
import { STAGE_LABELS } from './opportunityUi';
|
import { STAGE_LABELS } from './opportunityUi';
|
||||||
import { ConfirmDialog } from '../ui/Modal';
|
import { ConfirmDialog } from '../ui/Modal';
|
||||||
import { useToast } from '../Toast';
|
import { useToast } from '../Toast';
|
||||||
@@ -113,6 +114,8 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Opportunity | null>(null);
|
const [editing, setEditing] = useState<Opportunity | null>(null);
|
||||||
const [toDelete, setToDelete] = useState<Opportunity | null>(null);
|
const [toDelete, setToDelete] = useState<Opportunity | null>(null);
|
||||||
|
// Fuer welche Opportunity gerade Kopien fuer andere Berater angelegt werden.
|
||||||
|
const [toCopy, setToCopy] = useState<Opportunity | null>(null);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const terms = searchTerm
|
const terms = searchTerm
|
||||||
@@ -388,7 +391,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === 'grid' ? (
|
) : viewMode === 'grid' ? (
|
||||||
<OpportunityGrid opportunities={filtered} onEdit={openEdit} onDelete={setToDelete} />
|
<OpportunityGrid opportunities={filtered} onEdit={openEdit} onDelete={setToDelete} onCopy={setToCopy} />
|
||||||
) : viewMode === 'list' ? (
|
) : viewMode === 'list' ? (
|
||||||
<OpportunityList
|
<OpportunityList
|
||||||
opportunities={filtered}
|
opportunities={filtered}
|
||||||
@@ -396,6 +399,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
|
|||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={setToDelete}
|
onDelete={setToDelete}
|
||||||
|
onCopy={setToCopy}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<OpportunityPipeline
|
<OpportunityPipeline
|
||||||
@@ -403,6 +407,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
|
|||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onStageChange={changeStage}
|
onStageChange={changeStage}
|
||||||
onDelete={setToDelete}
|
onDelete={setToDelete}
|
||||||
|
onCopy={setToCopy}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -413,6 +418,19 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
|
|||||||
users={users}
|
users={users}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => setIsModalOpen(false)}
|
||||||
onSaved={reload}
|
onSaved={reload}
|
||||||
|
onCopy={(opportunity) => {
|
||||||
|
// Der Bearbeiten-Dialog schliesst, sonst laegen zwei Dialoge uebereinander.
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setToCopy(opportunity);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{toCopy && (
|
||||||
|
<OpportunityCopyDialog
|
||||||
|
opportunity={toCopy}
|
||||||
|
onClose={() => setToCopy(null)}
|
||||||
|
onCopied={reload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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<OpportunityCopyDialogProps> = ({
|
||||||
|
opportunity,
|
||||||
|
onClose,
|
||||||
|
onCopied,
|
||||||
|
}) => {
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const [options, setOptions] = useState<string[] | null>(null);
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [stage, setStage] = useState<OpportunityStage>('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 (
|
||||||
|
<Modal
|
||||||
|
title="Für anderen Berater kopieren"
|
||||||
|
subtitle={opportunity.title}
|
||||||
|
onClose={onClose}
|
||||||
|
size="md"
|
||||||
|
footer={
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={isSaving || selected.length === 0}
|
||||||
|
className="flex-[2] rounded-xl bg-brand-500 py-3 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSaving
|
||||||
|
? 'Kopiere...'
|
||||||
|
: selected.length > 1
|
||||||
|
? `${selected.length} Kopien anlegen`
|
||||||
|
: 'Kopie anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p className="text-sm text-ink-600">
|
||||||
|
Für jeden gewählten Berater entsteht eine eigene Opportunity mit denselben
|
||||||
|
Projektdaten, Ansprechpartnern und Notizen - aber eigener Phase.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-extrabold uppercase tracking-wider text-ink-500">Berater</p>
|
||||||
|
{options === null ? (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<span className="h-5 w-5 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
|
||||||
|
</div>
|
||||||
|
) : available.length === 0 ? (
|
||||||
|
<p className="rounded-xl border border-dashed border-ink-200 px-4 py-3 text-sm text-ink-500">
|
||||||
|
Keine weiteren Berater in der Liste. Gepflegt wird sie unter Einstellungen → Akquise.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{available.map((name) => {
|
||||||
|
const checked = selected.includes(name);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={name}
|
||||||
|
className={`flex cursor-pointer items-center gap-2.5 rounded-xl border px-3 py-2.5 text-sm font-semibold transition ${
|
||||||
|
checked
|
||||||
|
? 'border-brand-500 bg-brand-50 text-brand-800'
|
||||||
|
: 'border-ink-200 text-ink-700 hover:border-brand-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggle(name)}
|
||||||
|
className="h-4 w-4 accent-brand-500"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="copy-stage"
|
||||||
|
className="mb-2 block text-xs font-extrabold uppercase tracking-wider text-ink-500"
|
||||||
|
>
|
||||||
|
Phase der Kopie
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="copy-stage"
|
||||||
|
value={stage}
|
||||||
|
onChange={(event) => setStage(event.target.value as OpportunityStage)}
|
||||||
|
className="w-full rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
|
||||||
|
>
|
||||||
|
{OPPORTUNITY_STAGES.map((entry) => (
|
||||||
|
<option key={entry} value={entry}>
|
||||||
|
{STAGE_LABELS[entry]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -21,6 +21,8 @@ interface OpportunityGridProps {
|
|||||||
opportunities: Opportunity[];
|
opportunities: Opportunity[];
|
||||||
onEdit: (opportunity: Opportunity) => void;
|
onEdit: (opportunity: Opportunity) => void;
|
||||||
onDelete: (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 }> = ({
|
const Metric: React.FC<{ label: string; value: string; className?: string }> = ({
|
||||||
@@ -38,6 +40,7 @@ export const OpportunityGrid: React.FC<OpportunityGridProps> = ({
|
|||||||
opportunities,
|
opportunities,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onCopy,
|
||||||
}) => (
|
}) => (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{opportunities.map((opportunity) => {
|
{opportunities.map((opportunity) => {
|
||||||
@@ -76,6 +79,19 @@ export const OpportunityGrid: React.FC<OpportunityGridProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{/* Sichtbar, nicht erst beim Ueberfahren: ein Knopf, den man nur findet,
|
{/* Sichtbar, nicht erst beim Ueberfahren: ein Knopf, den man nur findet,
|
||||||
wenn man ihn schon kennt, ist keiner. */}
|
wenn man ihn schon kennt, ist keiner. */}
|
||||||
|
<button
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onCopy(opportunity);
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded-lg p-1.5 text-ink-400 transition hover:bg-brand-50 hover:text-brand-700"
|
||||||
|
title="Für anderen Berater kopieren"
|
||||||
|
aria-label={`${opportunity.title} für anderen Berater kopieren`}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ interface OpportunityListProps {
|
|||||||
onSort: (key: OpportunitySortKey) => void;
|
onSort: (key: OpportunitySortKey) => void;
|
||||||
onEdit: (opportunity: Opportunity) => void;
|
onEdit: (opportunity: Opportunity) => void;
|
||||||
onDelete: (opportunity: Opportunity) => void;
|
onDelete: (opportunity: Opportunity) => void;
|
||||||
|
/** Kopie fuer einen anderen Berater anlegen. */
|
||||||
|
onCopy: (opportunity: Opportunity) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OpportunityList: React.FC<OpportunityListProps> = ({
|
export const OpportunityList: React.FC<OpportunityListProps> = ({
|
||||||
@@ -88,6 +90,7 @@ export const OpportunityList: React.FC<OpportunityListProps> = ({
|
|||||||
onSort,
|
onSort,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onCopy,
|
||||||
}) => (
|
}) => (
|
||||||
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card">
|
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card">
|
||||||
<div className="scrollbar-slim overflow-x-auto">
|
<div className="scrollbar-slim overflow-x-auto">
|
||||||
@@ -246,7 +249,20 @@ export const OpportunityList: React.FC<OpportunityListProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="whitespace-nowrap px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onCopy(opportunity);
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-ink-400 transition hover:bg-brand-50 hover:text-brand-700"
|
||||||
|
title="Für anderen Berater kopieren"
|
||||||
|
aria-label={`${opportunity.title} für anderen Berater kopieren`}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface OpportunityModalProps {
|
|||||||
users: AppUser[];
|
users: AppUser[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
|
/** Kopie fuer einen anderen Berater - nur bei bestehenden Opportunities. */
|
||||||
|
onCopy?: (opportunity: Opportunity) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContactEntry {
|
interface ContactEntry {
|
||||||
@@ -85,6 +87,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
|
|||||||
users,
|
users,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
onCopy,
|
||||||
}) => {
|
}) => {
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const [form, setForm] = useState<FormState>(() => toFormState(opportunity, users[0]?.id ?? null));
|
const [form, setForm] = useState<FormState>(() => toFormState(opportunity, users[0]?.id ?? null));
|
||||||
@@ -268,6 +271,20 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
|
|||||||
size="xl"
|
size="xl"
|
||||||
footer={
|
footer={
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{opportunity && onCopy && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCopy(opportunity)}
|
||||||
|
disabled={isSaving}
|
||||||
|
title="Für anderen Berater kopieren - ungespeicherte Änderungen werden nicht mitkopiert"
|
||||||
|
className="flex items-center justify-center gap-2 rounded-xl border border-ink-200 px-4 py-3 text-sm font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
|
||||||
|
</svg>
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{/* Nur bei Opportunities aus der Akquise, deren Eintrag noch da ist. */}
|
{/* Nur bei Opportunities aus der Akquise, deren Eintrag noch da ist. */}
|
||||||
{opportunity?.acquisition_available && (
|
{opportunity?.acquisition_available && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface OpportunityPipelineProps {
|
|||||||
onStageChange: (opportunity: Opportunity, stage: OpportunityStage) => void;
|
onStageChange: (opportunity: Opportunity, stage: OpportunityStage) => void;
|
||||||
/** Legt die Opportunity in den Papierkorb. */
|
/** Legt die Opportunity in den Papierkorb. */
|
||||||
onDelete: (opportunity: Opportunity) => void;
|
onDelete: (opportunity: Opportunity) => void;
|
||||||
|
/** Kopie fuer einen anderen Berater anlegen. */
|
||||||
|
onCopy: (opportunity: Opportunity) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +38,7 @@ export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onStageChange,
|
onStageChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onCopy,
|
||||||
}) => {
|
}) => {
|
||||||
const [draggedId, setDraggedId] = useState<number | null>(null);
|
const [draggedId, setDraggedId] = useState<number | null>(null);
|
||||||
const [hoverStage, setHoverStage] = useState<OpportunityStage | null>(null);
|
const [hoverStage, setHoverStage] = useState<OpportunityStage | null>(null);
|
||||||
@@ -137,6 +140,19 @@ export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onCopy(opportunity);
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded-lg p-1 text-ink-400 transition hover:bg-brand-50 hover:text-brand-700"
|
||||||
|
title="Für anderen Berater kopieren"
|
||||||
|
aria-label={`${opportunity.title} für anderen Berater kopieren`}
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
@@ -232,6 +232,12 @@ export const opportunitiesApi = {
|
|||||||
`/opportunities/${id}/acquisition`,
|
`/opportunities/${id}/acquisition`,
|
||||||
{ method: 'POST' }
|
{ method: 'POST' }
|
||||||
),
|
),
|
||||||
|
/** Legt je Berater eine Kopie an - eigene Phase, gleiche Projektdaten. */
|
||||||
|
copy: (id: number, input: { consultants: string[]; stage?: Opportunity['stage'] }) =>
|
||||||
|
request<{ ok: true; opportunity_ids: number[] }>(`/opportunities/${id}/copy`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
emptyTrash: () =>
|
emptyTrash: () =>
|
||||||
request<{ ok: true; purged: number }>('/opportunities/trash/empty', { method: 'POST' }),
|
request<{ ok: true; purged: number }>('/opportunities/trash/empty', { method: 'POST' }),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user