Menüs, Dialoge, Schaltflächen, Platzhalter, Hinweise und die
Fehlermeldungen der API standen in Ersatzschreibweise (oe/ae/ue). Sie
erscheinen als Toast oder Beschriftung direkt in der Oberfläche und
gehören damit richtig geschrieben; sechs sichtbare Texte mit ss statt ß
sind gleich mit erledigt ("Schließen", "Straße", "zu groß").
Unangetastet bleibt bewusst:
- Kommentare im Quelltext - die sind durchgängig ASCII, und der Diff wäre
dreimal so groß ohne Nutzen für den Benutzer
- Protokollzeilen (console.*) - die landen im journal, nicht in der App
- Datenbankwerte und interne Kennungen: 'uebernommen', 'empfaenger',
'vorschlaege', 'laeuft-bereits' stecken in Enums, SQL und
Zustandsvergleichen. Ein Umlaut darin hätte still falsche Ergebnisse
geliefert
- server/src/mail/extract.js - die Muster decken absichtlich beide
Schreibweisen ab: /gr(ü|ue)(ß|ss)e/
- scanner/utils/crypto.js - der Ersatzschlüssel darf sich nicht ändern,
sonst sind gespeicherte Portal-Passwörter nicht mehr lesbar
Geprüft mit einem Rundgang durch die Ansichten und Dialoge: sichtbarer
Text plus title/aria-label/placeholder enthalten keine
Ersatzschreibweisen mehr.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
195 lines
8.5 KiB
TypeScript
195 lines
8.5 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { UserAvatar } from '../ui/UserAvatar';
|
|
import { STAGE_COLORS, StageBadge, formatDate, formatDays, relativeDays } from './opportunityUi';
|
|
import {
|
|
OPPORTUNITY_STAGES,
|
|
contactFullName,
|
|
formatEuro,
|
|
opportunityVolume,
|
|
type Opportunity,
|
|
type OpportunityStage,
|
|
} from '../../types';
|
|
|
|
interface OpportunityPipelineProps {
|
|
opportunities: Opportunity[];
|
|
onEdit: (opportunity: Opportunity) => void;
|
|
/** Verschiebt eine Karte in eine andere Phase. */
|
|
onStageChange: (opportunity: Opportunity, stage: OpportunityStage) => void;
|
|
}
|
|
|
|
/**
|
|
* Pipeline nach Phasen. Karten lassen sich per Drag & Drop in eine andere Spalte
|
|
* ziehen; wer nicht ziehen mag, nutzt das Auswahlfeld auf der Karte.
|
|
*/
|
|
export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
|
|
opportunities,
|
|
onEdit,
|
|
onStageChange,
|
|
}) => {
|
|
const [draggedId, setDraggedId] = useState<number | null>(null);
|
|
const [hoverStage, setHoverStage] = useState<OpportunityStage | null>(null);
|
|
|
|
const byStage = (stage: OpportunityStage) =>
|
|
opportunities.filter((opportunity) => opportunity.stage === stage);
|
|
|
|
const columnSum = (stage: OpportunityStage) =>
|
|
byStage(stage).reduce((sum, opportunity) => sum + (opportunityVolume(opportunity) ?? 0), 0);
|
|
|
|
const handleDrop = (stage: OpportunityStage) => {
|
|
setHoverStage(null);
|
|
const dragged = opportunities.find((opportunity) => opportunity.id === draggedId);
|
|
setDraggedId(null);
|
|
if (dragged && dragged.stage !== stage) onStageChange(dragged, stage);
|
|
};
|
|
|
|
return (
|
|
<div className="scrollbar-slim overflow-x-auto pb-2">
|
|
<div className="flex gap-3" style={{ minWidth: `${OPPORTUNITY_STAGES.length * 250}px` }}>
|
|
{OPPORTUNITY_STAGES.map((stage) => {
|
|
const items = byStage(stage);
|
|
const color = STAGE_COLORS[stage];
|
|
const isTarget = hoverStage === stage;
|
|
|
|
return (
|
|
<div
|
|
key={stage}
|
|
data-stage={stage}
|
|
onDragOver={(event) => {
|
|
// Ohne preventDefault laesst der Browser kein Ablegen zu.
|
|
event.preventDefault();
|
|
setHoverStage(stage);
|
|
}}
|
|
onDragLeave={() => setHoverStage((current) => (current === stage ? null : current))}
|
|
onDrop={() => handleDrop(stage)}
|
|
className={`flex w-[250px] shrink-0 flex-col rounded-2xl border transition ${
|
|
isTarget ? 'border-brand-400 bg-brand-50/60' : 'border-ink-200 bg-ink-50/60'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2 border-b border-ink-100 px-3 py-2.5">
|
|
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
|
<span className="text-xs font-extrabold uppercase tracking-wider text-ink-600">
|
|
{stage}
|
|
</span>
|
|
<span className="ml-auto rounded-md bg-ink-100 px-1.5 py-0.5 text-[10px] font-extrabold text-ink-500">
|
|
{items.length}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="border-b border-ink-100 px-3 py-1.5 text-[11px] font-bold text-ink-400">
|
|
{formatEuro(columnSum(stage))}
|
|
</div>
|
|
|
|
<div className="scrollbar-slim flex max-h-[62vh] flex-1 flex-col gap-2 overflow-y-auto p-2">
|
|
{items.length === 0 ? (
|
|
<p className="rounded-xl border border-dashed border-ink-200 px-3 py-6 text-center text-[11px] font-semibold text-ink-300">
|
|
Karte hierher ziehen
|
|
</p>
|
|
) : (
|
|
items.map((opportunity) => {
|
|
const interaction = relativeDays(opportunity.last_interaction_at);
|
|
|
|
return (
|
|
<div
|
|
key={opportunity.id}
|
|
draggable
|
|
onDragStart={() => setDraggedId(opportunity.id)}
|
|
onDragEnd={() => {
|
|
setDraggedId(null);
|
|
setHoverStage(null);
|
|
}}
|
|
onClick={() => onEdit(opportunity)}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
onEdit(opportunity);
|
|
}
|
|
}}
|
|
className={`cursor-grab rounded-xl border border-ink-200 bg-surface p-2.5 shadow-card transition hover:border-brand-300 active:cursor-grabbing ${
|
|
draggedId === opportunity.id ? 'opacity-40' : ''
|
|
}`}
|
|
>
|
|
<p className="truncate text-xs font-extrabold text-ink-900">
|
|
{opportunity.title}
|
|
</p>
|
|
<p className="truncate text-[11px] text-ink-400">
|
|
{opportunity.company || 'Ohne Firma'}
|
|
</p>
|
|
|
|
<div className="mt-2 flex items-center justify-between gap-2">
|
|
<span className="text-xs font-bold text-ink-800">
|
|
{formatEuro(opportunityVolume(opportunity))}
|
|
</span>
|
|
<span className="text-[11px] font-bold tabular-nums text-ink-400">
|
|
{opportunity.probability}%
|
|
</span>
|
|
</div>
|
|
|
|
<p className="mt-1 text-[11px] text-ink-400">
|
|
{formatDays(opportunity.duration_days)}
|
|
{opportunity.start_date && ` ab ${formatDate(opportunity.start_date)}`}
|
|
</p>
|
|
|
|
{interaction && (
|
|
<p className="mt-1 text-[11px] text-ink-400">Kontakt {interaction}</p>
|
|
)}
|
|
|
|
<div className="mt-2 flex items-center gap-2 border-t border-ink-100 pt-2">
|
|
<span className="min-w-0 flex-1 truncate text-[11px] text-ink-500">
|
|
{opportunity.contacts.length === 0
|
|
? 'kein Ansprechpartner'
|
|
: opportunity.contacts.length === 1
|
|
? contactFullName(opportunity.contacts[0])
|
|
: `${opportunity.contacts.length} Ansprechpartner`}
|
|
</span>
|
|
{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={20}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tastatur- und Touch-Weg ohne Drag & Drop */}
|
|
<select
|
|
value={opportunity.stage}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onChange={(event) => {
|
|
event.stopPropagation();
|
|
onStageChange(opportunity, event.target.value as OpportunityStage);
|
|
}}
|
|
aria-label={`Phase von ${opportunity.title} ändern`}
|
|
className="mt-2 w-full rounded-lg border border-ink-200 bg-surface px-2 py-1 text-[11px] font-bold text-ink-600 outline-none transition focus:border-brand-500"
|
|
>
|
|
{OPPORTUNITY_STAGES.map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Legende der Phasen, damit die Farben auch ausserhalb der Pipeline sitzen */}
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{OPPORTUNITY_STAGES.map((stage) => (
|
|
<StageBadge key={stage} stage={stage} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|