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:
co-authored by
Claude Opus 5
parent
4635b9a64b
commit
64dea0b100
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 '—';
|
||||||
|
|||||||
Reference in New Issue
Block a user