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
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user