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 > Gewonnen, Verloren) ließ nicht erkennen, in welcher der neuen Phasen ein
> Projekt steckt. > Projekt steckt.
**Volumen** rechnet die Anwendung selbst: Tagessatz x Personentage. Das **Volumen** rechnet die Anwendung selbst: Tagessatz x Personentage. Die Zahl
**gewichtete** Volumen multipliziert das noch mit der Abschlusswahrscheinlichkeit. steht live im Formular, auf jeder Karte, als Summe über der Liste, je
Beide Zahlen stehen live im Formular, auf jeder Karte, als Summe über der Liste, Pipeline-Spalte und in der Kopfzeile.
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 Nicht jedes Projekt rechnet nach Aufwand. Für Festpreise gibt es unter
*Projektdaten* das Feld **Projektpreis**. Steht dort eine Zahl, gilt sie als *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) | | `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 | | `MAIL_LEAD_SOURCE`, `LeadOriginFilter` | Herkunft „Mail" in `Lead.source` und ihr Filter |
| `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id | | `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 | | `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 | | `ACQUISITION_OPTION_FIELDS`, `ACQUISITION_FIELD_LABELS`, `ACQUISITION_FIELD_HINTS` | die vier Auswahllisten der Akquise samt Beschriftung |
| `eventColor()`, `leadFullName()`, `leadInitials()`, `contactFullName()` | Anzeigelogik | | `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 Anzeige** | `display_threshold` - Treffer darunter bleiben im Dashboard ausgeblendet. |
| **Schwellenwert Benachrichtigung** | `match_threshold` - ab hier gibt es Signalton und E-Mail. | | **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`. | | **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. | | **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. | | **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. | | **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, contactFullName,
formatEuro, formatEuro,
opportunityVolume, opportunityVolume,
weightedVolume,
type AppUser, type AppUser,
type Lead, type Lead,
type Opportunity, type Opportunity,
@@ -54,7 +53,6 @@ const SORT_OPTIONS: OpportunitySortKey[] = [
'updated_at', 'updated_at',
'last_interaction', 'last_interaction',
'volume', 'volume',
'weighted',
'daily_rate', 'daily_rate',
'duration', 'duration',
'probability', 'probability',
@@ -77,8 +75,6 @@ function sortValue(opportunity: Opportunity, key: OpportunitySortKey): string |
return opportunity.probability; return opportunity.probability;
case 'volume': case 'volume':
return opportunityVolume(opportunity); return opportunityVolume(opportunity);
case 'weighted':
return weightedVolume(opportunity);
case 'daily_rate': case 'daily_rate':
return opportunity.daily_rate; return opportunity.daily_rate;
case 'duration': case 'duration':
@@ -167,7 +163,6 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
const totals = useMemo( const totals = useMemo(
() => ({ () => ({
volume: filtered.reduce((sum, item) => sum + (opportunityVolume(item) ?? 0), 0), 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, open: filtered.filter((item) => OPEN_STAGES.includes(item.stage)).length,
}), }),
[filtered] [filtered]
@@ -346,7 +341,6 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
{ label: 'Opportunities', value: String(filtered.length) }, { label: 'Opportunities', value: String(filtered.length) },
{ label: 'davon offen', value: String(totals.open) }, { label: 'davon offen', value: String(totals.open) },
{ label: 'Volumen', value: formatEuro(totals.volume) }, { label: 'Volumen', value: formatEuro(totals.volume) },
{ label: 'Gewichtet', value: formatEuro(totals.weighted) },
].map((metric) => ( ].map((metric) => (
<div key={metric.label}> <div key={metric.label}>
<p className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400"> <p className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
@@ -12,7 +12,6 @@ import {
contactFullName, contactFullName,
formatEuro, formatEuro,
opportunityVolume, opportunityVolume,
weightedVolume,
type Opportunity, type Opportunity,
} from '../../types'; } from '../../types';
@@ -23,7 +22,6 @@ export type OpportunitySortKey =
| 'stage' | 'stage'
| 'probability' | 'probability'
| 'volume' | 'volume'
| 'weighted'
| 'daily_rate' | 'daily_rate'
| 'duration' | 'duration'
| 'last_interaction' | 'last_interaction'
@@ -42,7 +40,6 @@ export const SORT_LABELS: Record<OpportunitySortKey, string> = {
stage: 'Phase', stage: 'Phase',
probability: 'Chance', probability: 'Chance',
volume: 'Volumen', volume: 'Volumen',
weighted: 'Gewichtet',
daily_rate: 'Tagessatz', daily_rate: 'Tagessatz',
duration: 'Laufzeit', duration: 'Laufzeit',
last_interaction: 'Letzte Interaktion', last_interaction: 'Letzte Interaktion',
@@ -54,7 +51,6 @@ export const SORT_LABELS: Record<OpportunitySortKey, string> = {
export const DESC_FIRST: OpportunitySortKey[] = [ export const DESC_FIRST: OpportunitySortKey[] = [
'probability', 'probability',
'volume', 'volume',
'weighted',
'daily_rate', 'daily_rate',
'duration', 'duration',
'last_interaction', 'last_interaction',
@@ -170,9 +166,6 @@ export const OpportunityList: React.FC<OpportunityListProps> = ({
<span className="text-sm font-bold tabular-nums text-ink-700"> <span className="text-sm font-bold tabular-nums text-ink-700">
{opportunity.probability}% {opportunity.probability}%
</span> </span>
<p className="whitespace-nowrap text-[11px] text-ink-400">
{formatEuro(weightedVolume(opportunity))}
</p>
</td> </td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm font-bold text-ink-800"> <td className="whitespace-nowrap px-4 py-3 text-right text-sm font-bold text-ink-800">
{formatEuro(opportunityVolume(opportunity))} {formatEuro(opportunityVolume(opportunity))}
@@ -111,7 +111,6 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
// Festpreis sofort sichtbar ist. Ein gesetzter Projektpreis gewinnt - genau // Festpreis sofort sichtbar ist. Ein gesetzter Projektpreis gewinnt - genau
// wie in opportunityVolume(). // wie in opportunityVolume().
const preview = useMemo(() => { const preview = useMemo(() => {
const probability = Number(form.probability) || 0;
const toNumber = (value: string) => Number(value.replace(',', '.')); const toNumber = (value: string) => Number(value.replace(',', '.'));
const fixed = toNumber(form.fixed_price); const fixed = toNumber(form.fixed_price);
@@ -126,9 +125,9 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
Number.isFinite(days); Number.isFinite(days);
if (!hasFixed && !hasRate) return null; if (!hasFixed && !hasRate) return null;
const volume = hasFixed ? fixed : rate * days; // Kein gewichtetes Volumen: die Wahrscheinlichkeit steht fuer sich.
return { volume, weighted: (volume * probability) / 100, isFixed: hasFixed }; return { volume: hasFixed ? fixed : rate * days, isFixed: hasFixed };
}, [form.daily_rate, form.duration_days, form.fixed_price, form.probability]); }, [form.daily_rate, form.duration_days, form.fixed_price]);
const addTags = () => { const addTags = () => {
const parsed = tagInput const parsed = tagInput
@@ -418,14 +417,6 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
</p> </p>
<p className="text-lg font-extrabold text-ink-900">{formatEuro(preview.volume)}</p> <p className="text-lg font-extrabold text-ink-900">{formatEuro(preview.volume)}</p>
</div> </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> </div>
)} )}
</section> </section>
+6 -5
View File
@@ -405,11 +405,12 @@ export function opportunityVolume(opportunity: Opportunity): number | null {
return opportunity.daily_rate * opportunity.duration_days; return opportunity.daily_rate * opportunity.duration_days;
} }
/** Mit der Abschlusswahrscheinlichkeit gewichtetes Volumen. */ /*
export function weightedVolume(opportunity: Opportunity): number | null { * Ein mit der Abschlusswahrscheinlichkeit gewichtetes Volumen gibt es hier
const volume = opportunityVolume(opportunity); * bewusst nicht (am 18.08.2026 entfernt): so wird bei perinco nicht gerechnet.
return volume === null ? null : (volume * opportunity.probability) / 100; * 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 { export function formatEuro(value: number | null): string {
if (value === null) return '—'; if (value === null) return '—';