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:
Sascha Wolf
2026-09-17 13:31:59 +02:00
co-authored by Claude Opus 5
parent 33dedaedfb
commit 6724d0ab65
10 changed files with 392 additions and 3 deletions
+19 -1
View File
@@ -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<OpportunitiesProps> = ({
const [isModalOpen, setIsModalOpen] = useState(false);
const [editing, setEditing] = 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 terms = searchTerm
@@ -388,7 +391,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
)}
</div>
) : viewMode === 'grid' ? (
<OpportunityGrid opportunities={filtered} onEdit={openEdit} onDelete={setToDelete} />
<OpportunityGrid opportunities={filtered} onEdit={openEdit} onDelete={setToDelete} onCopy={setToCopy} />
) : viewMode === 'list' ? (
<OpportunityList
opportunities={filtered}
@@ -396,6 +399,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
onSort={handleSort}
onEdit={openEdit}
onDelete={setToDelete}
onCopy={setToCopy}
/>
) : (
<OpportunityPipeline
@@ -403,6 +407,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
onEdit={openEdit}
onStageChange={changeStage}
onDelete={setToDelete}
onCopy={setToCopy}
/>
)}
@@ -413,6 +418,19 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
users={users}
onClose={() => setIsModalOpen(false)}
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[];
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<OpportunityGridProps> = ({
opportunities,
onEdit,
onDelete,
onCopy,
}) => (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{opportunities.map((opportunity) => {
@@ -76,6 +79,19 @@ export const OpportunityGrid: React.FC<OpportunityGridProps> = ({
</div>
{/* Sichtbar, nicht erst beim Ueberfahren: ein Knopf, den man nur findet,
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
onClick={(event) => {
event.stopPropagation();
@@ -80,6 +80,8 @@ interface OpportunityListProps {
onSort: (key: OpportunitySortKey) => void;
onEdit: (opportunity: Opportunity) => void;
onDelete: (opportunity: Opportunity) => void;
/** Kopie fuer einen anderen Berater anlegen. */
onCopy: (opportunity: Opportunity) => void;
}
export const OpportunityList: React.FC<OpportunityListProps> = ({
@@ -88,6 +90,7 @@ export const OpportunityList: React.FC<OpportunityListProps> = ({
onSort,
onEdit,
onDelete,
onCopy,
}) => (
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card">
<div className="scrollbar-slim overflow-x-auto">
@@ -246,7 +249,20 @@ export const OpportunityList: React.FC<OpportunityListProps> = ({
/>
)}
</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
onClick={(event) => {
event.stopPropagation();
@@ -25,6 +25,8 @@ interface OpportunityModalProps {
users: AppUser[];
onClose: () => void;
onSaved: () => void;
/** Kopie fuer einen anderen Berater - nur bei bestehenden Opportunities. */
onCopy?: (opportunity: Opportunity) => void;
}
interface ContactEntry {
@@ -85,6 +87,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
users,
onClose,
onSaved,
onCopy,
}) => {
const { addToast } = useToast();
const [form, setForm] = useState<FormState>(() => toFormState(opportunity, users[0]?.id ?? null));
@@ -268,6 +271,20 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
size="xl"
footer={
<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. */}
{opportunity?.acquisition_available && (
<button
@@ -25,6 +25,8 @@ interface OpportunityPipelineProps {
onStageChange: (opportunity: Opportunity, stage: OpportunityStage) => void;
/** Legt die Opportunity in den Papierkorb. */
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,
onStageChange,
onDelete,
onCopy,
}) => {
const [draggedId, setDraggedId] = useState<number | null>(null);
const [hoverStage, setHoverStage] = useState<OpportunityStage | null>(null);
@@ -137,6 +140,19 @@ export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
</p>
)}
</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
onClick={(event) => {
event.stopPropagation();
+6
View File
@@ -232,6 +232,12 @@ export const opportunitiesApi = {
`/opportunities/${id}/acquisition`,
{ 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: () =>
request<{ ok: true; purged: number }>('/opportunities/trash/empty', { method: 'POST' }),
};