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>
238 lines
9.3 KiB
TypeScript
238 lines
9.3 KiB
TypeScript
import React from 'react';
|
||
import { UserAvatar } from '../ui/UserAvatar';
|
||
import { Avatar } from '../leads/leadUi';
|
||
import {
|
||
CollapsibleText,
|
||
ProbabilityBar,
|
||
STAGE_COLORS,
|
||
StageBadge,
|
||
formatDate,
|
||
formatDays,
|
||
relativeDays,
|
||
} from './opportunityUi';
|
||
import {
|
||
contactFullName,
|
||
formatEuro,
|
||
opportunityVolume,
|
||
type Opportunity,
|
||
} from '../../types';
|
||
|
||
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 }> = ({
|
||
label,
|
||
value,
|
||
className = '',
|
||
}) => (
|
||
<div className={className}>
|
||
<p className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400">{label}</p>
|
||
<p className="truncate text-sm font-bold text-ink-800">{value}</p>
|
||
</div>
|
||
);
|
||
|
||
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) => {
|
||
const color = STAGE_COLORS[opportunity.stage];
|
||
const interaction = relativeDays(opportunity.last_interaction_at);
|
||
|
||
return (
|
||
<div
|
||
key={opportunity.id}
|
||
onClick={() => onEdit(opportunity)}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
onEdit(opportunity);
|
||
}
|
||
}}
|
||
className="group flex cursor-pointer flex-col gap-3 overflow-hidden rounded-2xl border border-ink-200 bg-surface p-4 shadow-card transition hover:border-brand-300 hover:shadow-lift"
|
||
>
|
||
{/* Kopf */}
|
||
<div className="flex items-start gap-3">
|
||
<span className="mt-1 h-8 w-1.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm font-extrabold text-ink-900 transition group-hover:text-brand-700">
|
||
{opportunity.title}
|
||
</p>
|
||
<p className="truncate text-xs text-ink-400">{opportunity.company || 'Ohne Firma'}</p>
|
||
{/* Fuer wen das Projekt laeuft - hervorgehoben, weil es die
|
||
Kachel sonst nicht hergibt. */}
|
||
{opportunity.consultant && (
|
||
<p className="mt-1 truncate text-xs font-bold text-brand-700">
|
||
{opportunity.consultant}
|
||
</p>
|
||
)}
|
||
</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();
|
||
onDelete(opportunity);
|
||
}}
|
||
className="shrink-0 rounded-lg p-1.5 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
|
||
title="In den Papierkorb legen"
|
||
aria-label={`${opportunity.title} löschen`}
|
||
>
|
||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<StageBadge stage={opportunity.stage} />
|
||
{opportunity.tags.slice(0, 2).map((tag) => (
|
||
<span
|
||
key={tag}
|
||
className="rounded-md bg-brand-500 px-2 py-0.5 text-[10px] font-bold text-white"
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
{opportunity.tags.length > 2 && (
|
||
<span className="text-[10px] font-bold text-ink-400">
|
||
+{opportunity.tags.length - 2}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<ProbabilityBar value={opportunity.probability} color={color} />
|
||
|
||
{/* Projektdaten */}
|
||
<div className="grid grid-cols-2 gap-3 border-t border-ink-100 pt-3">
|
||
<Metric label="Volumen" value={formatEuro(opportunityVolume(opportunity))} />
|
||
{/* Festpreisprojekte haben keinen Tagessatz - dann zaehlt der Preis. */}
|
||
{opportunity.fixed_price !== null ? (
|
||
<Metric label="Projektpreis" value={formatEuro(opportunity.fixed_price)} />
|
||
) : (
|
||
<Metric label="Tagessatz" value={formatEuro(opportunity.daily_rate)} />
|
||
)}
|
||
<Metric label="Laufzeit" value={formatDays(opportunity.duration_days)} />
|
||
<Metric
|
||
label="Zeitraum"
|
||
value={
|
||
opportunity.start_date || opportunity.end_date
|
||
? `${formatDate(opportunity.start_date)} – ${formatDate(opportunity.end_date)}`
|
||
: '—'
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<CollapsibleText text={opportunity.description} />
|
||
|
||
{/* Letzte Interaktion */}
|
||
<div className="rounded-xl border border-ink-100 px-3 py-2">
|
||
<p className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
|
||
Letzte Interaktion
|
||
</p>
|
||
<p className="text-xs font-bold text-ink-700">
|
||
{formatDate(opportunity.last_interaction_at)}
|
||
{interaction && <span className="ml-1 font-semibold text-ink-400">({interaction})</span>}
|
||
</p>
|
||
{opportunity.last_interaction_note && (
|
||
<p className="mt-0.5 line-clamp-2 text-xs text-ink-500">
|
||
{opportunity.last_interaction_note}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{opportunity.next_step && (
|
||
<div className="flex items-start gap-2 rounded-xl bg-brand-50 px-3 py-2">
|
||
<svg
|
||
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-brand-700"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||
</svg>
|
||
<p className="line-clamp-2 text-xs font-semibold text-brand-800">
|
||
{opportunity.next_step}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Ansprechpartner und Zustaendigkeit */}
|
||
<div className="mt-auto flex items-center gap-2 border-t border-ink-100 pt-3">
|
||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||
{opportunity.contacts.length === 0 ? (
|
||
<span className="text-[11px] font-semibold text-ink-300">
|
||
Kein Ansprechpartner
|
||
</span>
|
||
) : (
|
||
<>
|
||
{opportunity.contacts.slice(0, 3).map((contact) => (
|
||
<Avatar
|
||
key={contact.lead_id}
|
||
src={contact.avatar}
|
||
initials={`${contact.first_name.charAt(0)}${contact.last_name.charAt(0)}`.toUpperCase()}
|
||
size="sm"
|
||
/>
|
||
))}
|
||
{opportunity.contacts.length > 3 ? (
|
||
<span className="text-[11px] font-bold text-ink-400">
|
||
+{opportunity.contacts.length - 3}
|
||
</span>
|
||
) : (
|
||
opportunity.contacts.length === 1 && (
|
||
<span className="truncate text-[11px] font-semibold text-ink-500">
|
||
{contactFullName(opportunity.contacts[0])}
|
||
</span>
|
||
)
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{opportunity.owner_id !== null && (
|
||
<UserAvatar
|
||
user={{
|
||
id: opportunity.owner_id,
|
||
name: opportunity.owner_name ?? '?',
|
||
color: opportunity.owner_color ?? null,
|
||
avatar: opportunity.owner_avatar ?? null,
|
||
}}
|
||
size={24}
|
||
title={`Zuständig: ${opportunity.owner_name ?? 'unbekannt'}`}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|