Files
perinco-CRM/src/components/opportunities/OpportunityGrid.tsx
T
Sascha WolfandClaude Opus 5 6724d0ab65 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>
2026-09-17 13:31:59 +02:00

238 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);