feat(opportunities): gewichtetes Volumen entfernt

So wird bei perinco nicht gerechnet: die Summe "Gewichtet" aenderte sich beim
Schieben der Abschlusswahrscheinlichkeit und behauptete damit eine Groesse, die
es im Haus nicht gibt.

Weg sind die Kennzahl ueber der Liste, die Spalte samt Sortierung, die zweite
Zahl in der Vorschau des Formulars und weightedVolume() selbst. Die
Wahrscheinlichkeit bleibt an der Opportunity - Schieberegler im Formular, Prozent
in Liste und Pipeline-Karte; sie geht nur in keine Summe mehr ein.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-18 16:17:53 +02:00
co-authored by Claude Opus 5
parent 4635b9a64b
commit 64dea0b100
7 changed files with 19 additions and 36 deletions
+8 -4
View File
@@ -263,10 +263,14 @@ der Kopfzeile.
> Gewonnen, Verloren) ließ nicht erkennen, in welcher der neuen Phasen ein
> Projekt steckt.
**Volumen** rechnet die Anwendung selbst: Tagessatz x Personentage. Das
**gewichtete** Volumen multipliziert das noch mit der Abschlusswahrscheinlichkeit.
Beide Zahlen stehen live im Formular, auf jeder Karte, als Summe über der Liste,
je Pipeline-Spalte und in der Kopfzeile.
**Volumen** rechnet die Anwendung selbst: Tagessatz x Personentage. Die Zahl
steht live im Formular, auf jeder Karte, als Summe über der Liste, je
Pipeline-Spalte und in der Kopfzeile.
Ein mit der **Abschlusswahrscheinlichkeit gewichtetes** Volumen gibt es nicht
mehr (entfernt am 18.08.2026) - so wird bei perinco nicht gerechnet. Die
Wahrscheinlichkeit selbst bleibt an der Opportunity und steht in Liste und
Pipeline; sie geht nur in keine Summe mehr ein.
Nicht jedes Projekt rechnet nach Aufwand. Für Festpreise gibt es unter
*Projektdaten* das Feld **Projektpreis**. Steht dort eine Zahl, gilt sie als
+1 -1
View File
@@ -301,7 +301,7 @@ nicht auseinanderlaufen:
| `VIEW_TITLES`, `viewTitle()` | abweichende Beschriftung, wo der Ansichtsname nur intern eindeutig sein muss (`Opportunity-Papierkorb` → Papierkorb) |
| `MAIL_LEAD_SOURCE`, `LeadOriginFilter` | Herkunft „Mail" in `Lead.source` und ihr Filter |
| `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id |
| `opportunityVolume()`, `weightedVolume()`, `formatEuro()` | Festpreis, sonst Tagessatz x Personentage; gewichtet; formatiert |
| `opportunityVolume()`, `formatEuro()` | Festpreis, sonst Tagessatz x Personentage; formatiert. Ein gewichtetes Volumen gibt es bewusst nicht |
| `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_KINDS`, `CONNECTION_TYPES` | die Aufzählungen der Datenbank |
| `ACQUISITION_OPTION_FIELDS`, `ACQUISITION_FIELD_LABELS`, `ACQUISITION_FIELD_HINTS` | die vier Auswahllisten der Akquise samt Beschriftung |
| `eventColor()`, `leadFullName()`, `leadInitials()`, `contactFullName()` | Anzeigelogik |
+1 -1
View File
@@ -37,7 +37,7 @@ Quelltext.
| **Schwellenwert Anzeige** | `display_threshold` - Treffer darunter bleiben im Dashboard ausgeblendet. |
| **Schwellenwert Benachrichtigung** | `match_threshold` - ab hier gibt es Signalton und E-Mail. |
| **Verbindung** | Kante zwischen zwei Kontakten im Netzwerkdiagramm, mit Art und Stärke. Tabelle `crm_lead_connections`. |
| **Volumen** | Tagessatz x Personentage - oder der **Projektpreis**, wenn einer eingetragen ist. **Gewichtet** heißt zusätzlich mit der Abschlusswahrscheinlichkeit multipliziert. |
| **Volumen** | Tagessatz x Personentage - oder der **Projektpreis**, wenn einer eingetragen ist. Ein mit der Abschlusswahrscheinlichkeit gewichtetes Volumen gibt es nicht (seit 18.08.2026 entfernt); so wird bei perinco nicht gerechnet. |
| **Whisper** | Das Spracherkennungsmodell für das Diktat. Läuft im Browser, nicht auf dem Server. |
| **Zugriffsrichtlinie** | `ApplicationAccessPolicy` in Exchange Online. Begrenzt eine App-only-Berechtigung auf einzelne Postfächer - ohne sie gilt sie tenant-weit. |
| **Zuständiger** / Owner | Benutzer, dem ein Kontakt oder eine Opportunity zugeordnet ist. Darf sie ändern, auch wenn er sie nicht angelegt hat. |
@@ -19,7 +19,6 @@ import {
contactFullName,
formatEuro,
opportunityVolume,
weightedVolume,
type AppUser,
type Lead,
type Opportunity,
@@ -54,7 +53,6 @@ const SORT_OPTIONS: OpportunitySortKey[] = [
'updated_at',
'last_interaction',
'volume',
'weighted',
'daily_rate',
'duration',
'probability',
@@ -77,8 +75,6 @@ function sortValue(opportunity: Opportunity, key: OpportunitySortKey): string |
return opportunity.probability;
case 'volume':
return opportunityVolume(opportunity);
case 'weighted':
return weightedVolume(opportunity);
case 'daily_rate':
return opportunity.daily_rate;
case 'duration':
@@ -167,7 +163,6 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
const totals = useMemo(
() => ({
volume: filtered.reduce((sum, item) => sum + (opportunityVolume(item) ?? 0), 0),
weighted: filtered.reduce((sum, item) => sum + (weightedVolume(item) ?? 0), 0),
open: filtered.filter((item) => OPEN_STAGES.includes(item.stage)).length,
}),
[filtered]
@@ -346,7 +341,6 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
{ label: 'Opportunities', value: String(filtered.length) },
{ label: 'davon offen', value: String(totals.open) },
{ label: 'Volumen', value: formatEuro(totals.volume) },
{ label: 'Gewichtet', value: formatEuro(totals.weighted) },
].map((metric) => (
<div key={metric.label}>
<p className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
@@ -12,7 +12,6 @@ import {
contactFullName,
formatEuro,
opportunityVolume,
weightedVolume,
type Opportunity,
} from '../../types';
@@ -23,7 +22,6 @@ export type OpportunitySortKey =
| 'stage'
| 'probability'
| 'volume'
| 'weighted'
| 'daily_rate'
| 'duration'
| 'last_interaction'
@@ -42,7 +40,6 @@ export const SORT_LABELS: Record<OpportunitySortKey, string> = {
stage: 'Phase',
probability: 'Chance',
volume: 'Volumen',
weighted: 'Gewichtet',
daily_rate: 'Tagessatz',
duration: 'Laufzeit',
last_interaction: 'Letzte Interaktion',
@@ -54,7 +51,6 @@ export const SORT_LABELS: Record<OpportunitySortKey, string> = {
export const DESC_FIRST: OpportunitySortKey[] = [
'probability',
'volume',
'weighted',
'daily_rate',
'duration',
'last_interaction',
@@ -170,9 +166,6 @@ export const OpportunityList: React.FC<OpportunityListProps> = ({
<span className="text-sm font-bold tabular-nums text-ink-700">
{opportunity.probability}%
</span>
<p className="whitespace-nowrap text-[11px] text-ink-400">
{formatEuro(weightedVolume(opportunity))}
</p>
</td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm font-bold text-ink-800">
{formatEuro(opportunityVolume(opportunity))}
@@ -111,7 +111,6 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
// Festpreis sofort sichtbar ist. Ein gesetzter Projektpreis gewinnt - genau
// wie in opportunityVolume().
const preview = useMemo(() => {
const probability = Number(form.probability) || 0;
const toNumber = (value: string) => Number(value.replace(',', '.'));
const fixed = toNumber(form.fixed_price);
@@ -126,9 +125,9 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
Number.isFinite(days);
if (!hasFixed && !hasRate) return null;
const volume = hasFixed ? fixed : rate * days;
return { volume, weighted: (volume * probability) / 100, isFixed: hasFixed };
}, [form.daily_rate, form.duration_days, form.fixed_price, form.probability]);
// Kein gewichtetes Volumen: die Wahrscheinlichkeit steht fuer sich.
return { volume: hasFixed ? fixed : rate * days, isFixed: hasFixed };
}, [form.daily_rate, form.duration_days, form.fixed_price]);
const addTags = () => {
const parsed = tagInput
@@ -418,14 +417,6 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
</p>
<p className="text-lg font-extrabold text-ink-900">{formatEuro(preview.volume)}</p>
</div>
<div>
<p className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
Gewichtet ({form.probability} %)
</p>
<p className="text-lg font-extrabold text-brand-700">
{formatEuro(preview.weighted)}
</p>
</div>
</div>
)}
</section>
+6 -5
View File
@@ -405,11 +405,12 @@ export function opportunityVolume(opportunity: Opportunity): number | null {
return opportunity.daily_rate * opportunity.duration_days;
}
/** Mit der Abschlusswahrscheinlichkeit gewichtetes Volumen. */
export function weightedVolume(opportunity: Opportunity): number | null {
const volume = opportunityVolume(opportunity);
return volume === null ? null : (volume * opportunity.probability) / 100;
}
/*
* Ein mit der Abschlusswahrscheinlichkeit gewichtetes Volumen gibt es hier
* bewusst nicht (am 18.08.2026 entfernt): so wird bei perinco nicht gerechnet.
* Die Wahrscheinlichkeit selbst bleibt an der Opportunity - sie sagt etwas, ohne
* dass daraus eine Summe gebildet werden muss.
*/
export function formatEuro(value: number | null): string {
if (value === null) return '—';