Use task categories cost per time unit to calculate global financial preview
This commit is contained in:
parent
f48f89ceab
commit
52ffbde6ff
|
@ -7,13 +7,12 @@ import { Estimation } from "../hooks/use-project-estimations";
|
||||||
export interface EstimationRangeProps {
|
export interface EstimationRangeProps {
|
||||||
project: Project,
|
project: Project,
|
||||||
estimation: Estimation
|
estimation: Estimation
|
||||||
sdFactor: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EstimationRange: FunctionalComponent<EstimationRangeProps> = ({ project, estimation, sdFactor }) => {
|
export const EstimationRange: FunctionalComponent<EstimationRangeProps> = ({ project, estimation }) => {
|
||||||
const roundUp = getRoundUpEstimations(project);
|
const roundUp = getRoundUpEstimations(project);
|
||||||
let e: number|string = roundUp ? Math.ceil(estimation.e) : estimation.e;
|
let e: number|string = roundUp ? Math.ceil(estimation.e) : estimation.e;
|
||||||
let sd: number|string = roundUp ? Math.ceil(estimation.sd * sdFactor) : (estimation.sd * sdFactor);
|
let sd: number|string = roundUp ? Math.ceil(estimation.sd) : estimation.sd;
|
||||||
const max = e+sd;
|
const max = e+sd;
|
||||||
const min = Math.max(e-sd, 0);
|
const min = Math.max(e-sd, 0);
|
||||||
if (!roundUp) {
|
if (!roundUp) {
|
||||||
|
|
|
@ -117,12 +117,12 @@ export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskE
|
||||||
[action.confidence]: action.value
|
[action.confidence]: action.value
|
||||||
};
|
};
|
||||||
|
|
||||||
if (estimations.likely <= estimations.optimistic) {
|
if (estimations.likely < estimations.optimistic) {
|
||||||
estimations.likely = estimations.optimistic + 1;
|
estimations.likely = estimations.optimistic;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (estimations.pessimistic <= estimations.likely) {
|
if (estimations.pessimistic < estimations.likely) {
|
||||||
estimations.pessimistic = estimations.likely + 1;
|
estimations.pessimistic = estimations.likely;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -39,30 +39,28 @@ const EstimationTab: FunctionalComponent<EstimationTabProps> = ({ project, dispa
|
||||||
onTaskRemove={onTaskRemove}
|
onTaskRemove={onTaskRemove}
|
||||||
onTaskLabelUpdate={onTaskLabelUpdate}
|
onTaskLabelUpdate={onTaskLabelUpdate}
|
||||||
onEstimationChange={onEstimationChange} />
|
onEstimationChange={onEstimationChange} />
|
||||||
<div class="columns">
|
|
||||||
<div class="column is-5 is-offset-7">
|
|
||||||
<RepartitionPreview project={project} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-3">
|
<div class="column is-3">
|
||||||
<TimePreview project={project} />
|
<TimePreview project={project} />
|
||||||
<FinancialPreview project={project} />
|
<RepartitionPreview project={project} />
|
||||||
{
|
|
||||||
Object.keys(project.tasks).length <= 20 ?
|
|
||||||
<div class="message noPrint">
|
|
||||||
<div class="message-body">
|
|
||||||
<p><strong>⚠️ Attention</strong></p>
|
|
||||||
<br />
|
|
||||||
<p>Votre projet ne contient pas assez de tâches pour que les niveaux de confiance soient fiables.</p>
|
|
||||||
<br />
|
|
||||||
<p>Un minimum de 20 tâches est conseillé pour obtenir une estimation pertinente.</p>
|
|
||||||
</div>
|
|
||||||
</div> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<FinancialPreview project={project} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
Object.keys(project.tasks).length <= 20 ?
|
||||||
|
<div class="message noPrint">
|
||||||
|
<div class="message-body">
|
||||||
|
<p><strong>⚠️ Attention</strong></p>
|
||||||
|
<p>Votre projet ne contient pas assez de tâches pour que les niveaux de confiance soient fiables. Un minimum de 20 tâches est conseillé pour obtenir une estimation pertinente.</p>
|
||||||
|
</div>
|
||||||
|
</div> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
<hr />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import { FunctionalComponent, h } from "preact";
|
import { FunctionalComponent, h } from "preact";
|
||||||
import { Project } from "../../models/project";
|
import { Project } from "../../models/project";
|
||||||
import { useProjectEstimations } from "../../hooks/use-project-estimations";
|
import { useProjectEstimations } from "../../hooks/use-project-estimations";
|
||||||
import { getCurrency } from "../../models/params";
|
import { getCurrency, defaults, getTaskCategoryCost } from "../../models/params";
|
||||||
|
import { getMinMaxCosts, Cost } from "../../util/stat";
|
||||||
|
import * as style from './style.module.css';
|
||||||
|
import ProjectTimeUnit from "../../components/project-time-unit";
|
||||||
|
|
||||||
export interface FinancialPreviewProps {
|
export interface FinancialPreviewProps {
|
||||||
project: Project
|
project: Project
|
||||||
|
@ -9,15 +12,17 @@ export interface FinancialPreviewProps {
|
||||||
|
|
||||||
const FinancialPreview: FunctionalComponent<FinancialPreviewProps> = ({ project }) => {
|
const FinancialPreview: FunctionalComponent<FinancialPreviewProps> = ({ project }) => {
|
||||||
const estimations = useProjectEstimations(project);
|
const estimations = useProjectEstimations(project);
|
||||||
const costPerTimeUnit = 500;
|
const costs = getMinMaxCosts(project, estimations.p99);
|
||||||
const maxCost = Math.ceil((estimations.p99.e + estimations.p99.sd) * costPerTimeUnit);
|
|
||||||
const minCost = Math.max(Math.ceil((estimations.p99.e - estimations.p99.sd) * costPerTimeUnit), 0);
|
|
||||||
return (
|
return (
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="table is-bordered is-striped is-fullwidth">
|
<table class="table is-bordered is-striped is-fullwidth">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th colSpan={2}>Prévisionnel financier</th>
|
<th colSpan={2}>
|
||||||
|
<span>Prévisionnel financier</span><br />
|
||||||
|
<span class="is-size-7 has-text-weight-normal">confiance >= 99.7%</span>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="is-narrow">Temps</th>
|
<th class="is-narrow">Temps</th>
|
||||||
|
@ -28,17 +33,13 @@ const FinancialPreview: FunctionalComponent<FinancialPreviewProps> = ({ project
|
||||||
<tr>
|
<tr>
|
||||||
<td class="is-narrow">Maximum</td>
|
<td class="is-narrow">Maximum</td>
|
||||||
<td>
|
<td>
|
||||||
<details>
|
<CostDetails project={project} cost={costs.max} />
|
||||||
<summary>~ {maxCost} {getCurrency(project)}</summary>
|
|
||||||
</details>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="is-narrow">Minimum</td>
|
<td class="is-narrow">Minimum</td>
|
||||||
<td>
|
<td>
|
||||||
<details>
|
<CostDetails project={project} cost={costs.min} />
|
||||||
<summary>~ {minCost} {getCurrency(project)}</summary>
|
|
||||||
</details>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -47,4 +48,37 @@ const FinancialPreview: FunctionalComponent<FinancialPreviewProps> = ({ project
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface CostDetailsProps {
|
||||||
|
project: Project
|
||||||
|
cost: Cost
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CostDetails:FunctionalComponent<CostDetailsProps> = ({ project, cost }) => {
|
||||||
|
return (
|
||||||
|
<details>
|
||||||
|
<summary><strong>
|
||||||
|
≈ {cost.totalCost} {getCurrency(project)}</strong>
|
||||||
|
<span class="is-pulled-right">{cost.totalTime} <ProjectTimeUnit project={project} /></span>
|
||||||
|
</summary>
|
||||||
|
<table class={`table is-fullwidth`}>
|
||||||
|
<tbody>
|
||||||
|
{
|
||||||
|
Object.keys(cost.details).map(taskCategoryId => {
|
||||||
|
const taskCategory = project.params.taskCategories[taskCategoryId];
|
||||||
|
const details = cost.details[taskCategoryId];
|
||||||
|
return (
|
||||||
|
<tr key={`task-category-cost-${taskCategory.id}`}>
|
||||||
|
<td class={`${style.noBorder} is-size-6`}>{taskCategory.label}</td>
|
||||||
|
<td class={`${style.noBorder} is-size-6`}>{details.cost} {getCurrency(project)}</td>
|
||||||
|
<td class={`${style.noBorder} is-size-6`}>{details.time} <ProjectTimeUnit project={project} /> × {getTaskCategoryCost(taskCategory)} {getCurrency(project)}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default FinancialPreview;
|
export default FinancialPreview;
|
||||||
|
|
|
@ -3,14 +3,14 @@ import { Project } from "../../models/project";
|
||||||
import { useProjectEstimations } from "../../hooks/use-project-estimations";
|
import { useProjectEstimations } from "../../hooks/use-project-estimations";
|
||||||
import { getCurrency, getRoundUpEstimations } from "../../models/params";
|
import { getCurrency, getRoundUpEstimations } from "../../models/params";
|
||||||
import ProjectTimeUnit from "../../components/project-time-unit";
|
import ProjectTimeUnit from "../../components/project-time-unit";
|
||||||
import { getTaskCategoryWeightedMean, getProjectWeightedMean } from "../../util/stat";
|
import { getTaskCategoryWeightedMean, getProjectWeightedMean, getTaskCategoriesMeanRepartition } from "../../util/stat";
|
||||||
|
|
||||||
export interface RepartitionPreviewProps {
|
export interface RepartitionPreviewProps {
|
||||||
project: Project
|
project: Project
|
||||||
}
|
}
|
||||||
|
|
||||||
const RepartitionPreview: FunctionalComponent<RepartitionPreviewProps> = ({ project }) => {
|
const RepartitionPreview: FunctionalComponent<RepartitionPreviewProps> = ({ project }) => {
|
||||||
const projectMean = getProjectWeightedMean(project);
|
const repartition = getTaskCategoriesMeanRepartition(project);
|
||||||
return (
|
return (
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="table is-bordered is-striped is-fullwidth">
|
<table class="table is-bordered is-striped is-fullwidth">
|
||||||
|
@ -26,11 +26,7 @@ const RepartitionPreview: FunctionalComponent<RepartitionPreviewProps> = ({ proj
|
||||||
<tbody>
|
<tbody>
|
||||||
{
|
{
|
||||||
Object.values(project.params.taskCategories).map(tc => {
|
Object.values(project.params.taskCategories).map(tc => {
|
||||||
let mean = getTaskCategoryWeightedMean(tc.id, project);
|
let percent = (repartition[tc.id] * 100).toFixed(0);
|
||||||
const percent = ((mean/projectMean) * 100).toFixed(0);
|
|
||||||
if (getRoundUpEstimations(project)) {
|
|
||||||
mean = Math.ceil(mean);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<tr key={`task-category-${tc.id}`}>
|
<tr key={`task-category-${tc.id}`}>
|
||||||
<td>{tc.label}</td>
|
<td>{tc.label}</td>
|
||||||
|
|
|
@ -24,15 +24,15 @@ const TimePreview: FunctionalComponent<TimePreviewProps> = ({ project }) => {
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="is-narrow">>= 99.7%</td>
|
<td class="is-narrow">>= 99.7%</td>
|
||||||
<td><EstimationRange project={project} estimation={estimations.p99} sdFactor={3} /></td>
|
<td><EstimationRange project={project} estimation={estimations.p99} /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="is-narrow">>= 90%</td>
|
<td class="is-narrow">>= 90%</td>
|
||||||
<td><EstimationRange project={project} estimation={estimations.p90} sdFactor={1.645} /></td>
|
<td><EstimationRange project={project} estimation={estimations.p90} /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="is-narrow">>= 68%</td>
|
<td class="is-narrow">>= 68%</td>
|
||||||
<td><EstimationRange project={project} estimation={estimations.p68} sdFactor={1} /></td>
|
<td><EstimationRange project={project} estimation={estimations.p68} /></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot class="noPrint">
|
<tfoot class="noPrint">
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { Task, TaskCategory, TaskCategoryID } from "../models/task";
|
import { Task, TaskCategory, TaskCategoryID } from "../models/task";
|
||||||
import { Project } from "../models/project";
|
import { Project } from "../models/project";
|
||||||
import { TaskCategoriesTableProps } from "../routes/project/task-categories-table";
|
import { TaskCategoriesTableProps } from "../routes/project/task-categories-table";
|
||||||
|
import { Estimation } from "../hooks/use-project-estimations";
|
||||||
|
import { getTaskCategoryCost } from "../models/params";
|
||||||
|
|
||||||
export function getTaskWeightedMean(t: Task): number {
|
export function getTaskWeightedMean(t: Task): number {
|
||||||
return (t.estimations.optimistic + (4*t.estimations.likely) + t.estimations.pessimistic) / 6;
|
return (t.estimations.optimistic + (4*t.estimations.likely) + t.estimations.pessimistic) / 6;
|
||||||
|
@ -30,3 +32,57 @@ export function getProjectStandardDeviation(p : Project): number {
|
||||||
return sum;
|
return sum;
|
||||||
}, 0));
|
}, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MeanRepartition {
|
||||||
|
[id: string]: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskCategoriesMeanRepartition(project: Project): MeanRepartition {
|
||||||
|
let projectMean = getProjectWeightedMean(project);
|
||||||
|
const repartition: MeanRepartition = {};
|
||||||
|
Object.values(project.params.taskCategories).forEach(tc => {
|
||||||
|
repartition[tc.id] = getTaskCategoryWeightedMean(tc.id, project) / projectMean;
|
||||||
|
if (Number.isNaN(repartition[tc.id])) repartition[tc.id] = 0;
|
||||||
|
});
|
||||||
|
return repartition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MinMaxCost {
|
||||||
|
max: Cost
|
||||||
|
min: Cost
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cost {
|
||||||
|
totalCost: number
|
||||||
|
totalTime: number
|
||||||
|
details: { [taskCategoryId: string]: { time: number, cost: number } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMinMaxCosts(project: Project, estimation: Estimation): MinMaxCost {
|
||||||
|
const max: Cost = {totalCost: 0, totalTime: 0, details: {}};
|
||||||
|
const min: Cost = {totalCost: 0, totalTime: 0, details: {}};
|
||||||
|
|
||||||
|
const repartition = getTaskCategoriesMeanRepartition(project);
|
||||||
|
|
||||||
|
Object.values(project.params.taskCategories).forEach(tc => {
|
||||||
|
const cost = getTaskCategoryCost(tc);
|
||||||
|
|
||||||
|
const maxTime = Math.ceil((estimation.e + estimation.sd) * repartition[tc.id]);
|
||||||
|
max.details[tc.id] = {
|
||||||
|
time: maxTime,
|
||||||
|
cost: maxTime * cost,
|
||||||
|
};
|
||||||
|
max.totalTime += max.details[tc.id].time;
|
||||||
|
max.totalCost += max.details[tc.id].cost;
|
||||||
|
|
||||||
|
const minTime = Math.ceil((estimation.e - estimation.sd) * repartition[tc.id]);
|
||||||
|
min.details[tc.id] = {
|
||||||
|
time: minTime,
|
||||||
|
cost: minTime * cost,
|
||||||
|
};
|
||||||
|
min.totalTime += min.details[tc.id].time;
|
||||||
|
min.totalCost += min.details[tc.id].cost;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { max, min };
|
||||||
|
}
|
Loading…
Reference in New Issue