Frontend/backend project structure
+ Base implementation of a differential synchronization based on Neil Fraser article/talk See https://www.youtube.com/watch?v=S2Hp_1jqpY8
This commit is contained in:
69
client/src/routes/project/estimation-tab.tsx
Normal file
69
client/src/routes/project/estimation-tab.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { FunctionalComponent, h, Fragment } from "preact";
|
||||
import { Project } from "../../models/project";
|
||||
import TaskTable from "./tasks-table";
|
||||
import TimePreview from "./time-preview";
|
||||
import FinancialPreview from "./financial-preview";
|
||||
import { addTask, updateTaskEstimation, removeTask, updateProjectLabel, updateTaskLabel, ProjectReducerActions } from "../../hooks/use-project-reducer";
|
||||
import { Task, TaskID, EstimationConfidence } from "../../models/task";
|
||||
import RepartitionPreview from "./repartition-preview";
|
||||
import { getHideFinancialPreviewOnPrint } from "../../models/params";
|
||||
|
||||
export interface EstimationTabProps {
|
||||
project: Project
|
||||
dispatch: (action: ProjectReducerActions) => void
|
||||
}
|
||||
|
||||
const EstimationTab: FunctionalComponent<EstimationTabProps> = ({ project, dispatch }) => {
|
||||
const onTaskAdd = (task: Task) => {
|
||||
dispatch(addTask(task));
|
||||
};
|
||||
|
||||
const onTaskRemove = (taskId: TaskID) => {
|
||||
dispatch(removeTask(taskId));
|
||||
}
|
||||
|
||||
const onTaskLabelUpdate = (taskId: TaskID, label: string) => {
|
||||
dispatch(updateTaskLabel(taskId, label));
|
||||
}
|
||||
|
||||
const onEstimationChange = (taskId: TaskID, confidence: EstimationConfidence, value: number) => {
|
||||
dispatch(updateTaskEstimation(taskId, confidence, value));
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div class="columns">
|
||||
<div class="column is-9">
|
||||
<TaskTable
|
||||
project={project}
|
||||
onTaskAdd={onTaskAdd}
|
||||
onTaskRemove={onTaskRemove}
|
||||
onTaskLabelUpdate={onTaskLabelUpdate}
|
||||
onEstimationChange={onEstimationChange} />
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<TimePreview project={project} />
|
||||
<RepartitionPreview project={project} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class={`column ${getHideFinancialPreviewOnPrint(project) ? 'noPrint': ''}`}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default EstimationTab;
|
19
client/src/routes/project/export-tab.tsx
Normal file
19
client/src/routes/project/export-tab.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { FunctionalComponent, h, Fragment } from "preact";
|
||||
import { Project } from "../../models/project";
|
||||
import { useProjectEstimations, Estimation } from "../../hooks/use-project-estimations";
|
||||
|
||||
export interface ExportTabProps {
|
||||
project: Project
|
||||
}
|
||||
|
||||
const ExportTab: FunctionalComponent<ExportTabProps> = ({ project }) => {
|
||||
return (
|
||||
<div>
|
||||
<label class="label is-size-4">Format JSON</label>
|
||||
<pre>{ JSON.stringify(project, null, 2) }</pre>
|
||||
<hr />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportTab;
|
85
client/src/routes/project/financial-preview.tsx
Normal file
85
client/src/routes/project/financial-preview.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { FunctionalComponent, h } from "preact";
|
||||
import { Project } from "../../models/project";
|
||||
import { useProjectEstimations } from "../../hooks/use-project-estimations";
|
||||
import { getCurrency, defaults, getTaskCategoryCost, getRoundUpEstimations } 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 {
|
||||
project: Project
|
||||
}
|
||||
|
||||
const FinancialPreview: FunctionalComponent<FinancialPreviewProps> = ({ project }) => {
|
||||
const estimations = useProjectEstimations(project);
|
||||
const costs = getMinMaxCosts(project, estimations.p99);
|
||||
const roundUp = getRoundUpEstimations(project);
|
||||
return (
|
||||
<div class="table-container">
|
||||
<table class="table is-bordered is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
<th class="is-narrow">Temps</th>
|
||||
<th>Coût</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="is-narrow">Maximum</td>
|
||||
<td>
|
||||
<CostDetails project={project} cost={costs.max} roundUp={roundUp} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="is-narrow">Minimum</td>
|
||||
<td>
|
||||
<CostDetails project={project} cost={costs.min} roundUp={roundUp} />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface CostDetailsProps {
|
||||
project: Project
|
||||
cost: Cost
|
||||
roundUp: boolean
|
||||
}
|
||||
|
||||
export const CostDetails:FunctionalComponent<CostDetailsProps> = ({ project, cost, roundUp }) => {
|
||||
return (
|
||||
<details>
|
||||
<summary><strong>
|
||||
≈ {cost.totalCost} {getCurrency(project)}</strong>
|
||||
<span class="is-pulled-right">{ roundUp ? Math.ceil(cost.totalTime) : cost.totalTime.toFixed(2) } <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`}>{ roundUp ? Math.ceil(details.time) : details.time.toFixed(2) } <ProjectTimeUnit project={project} /> × {getTaskCategoryCost(taskCategory)} {getCurrency(project)}</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinancialPreview;
|
65
client/src/routes/project/index.tsx
Normal file
65
client/src/routes/project/index.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { FunctionalComponent, h } from "preact";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import style from "./style.module.css";
|
||||
import { newProject } from "../../models/project";
|
||||
import { useProjectReducer, updateProjectLabel } from "../../hooks/use-project-reducer";
|
||||
import { getProjectStorageKey } from "../../util/storage";
|
||||
import { useLocalStorage } from "../../hooks/use-local-storage";
|
||||
import EditableText from "../../components/editable-text";
|
||||
import Tabs from "../../components/tabs";
|
||||
import EstimationTab from "./estimation-tab";
|
||||
import ParamsTab from "./params-tab";
|
||||
import ExportTab from "./export-tab";
|
||||
import { useServerSync } from "../../hooks/use-server-sync";
|
||||
|
||||
export interface ProjectProps {
|
||||
projectId: string
|
||||
}
|
||||
|
||||
const Project: FunctionalComponent<ProjectProps> = ({ projectId }) => {
|
||||
const projectStorageKey = getProjectStorageKey(projectId);
|
||||
const [ storedProject, storeProject ] = useLocalStorage(projectStorageKey, newProject(projectId));
|
||||
const [ project, dispatch ] = useProjectReducer(storedProject);
|
||||
useServerSync(project)
|
||||
|
||||
const onProjectLabelChange = (projectLabel: string) => {
|
||||
dispatch(updateProjectLabel(projectLabel));
|
||||
};
|
||||
|
||||
// Save project in local storage on change
|
||||
useEffect(()=> {
|
||||
storeProject(project);
|
||||
}, [project]);
|
||||
|
||||
return (
|
||||
<div class={`container ${style.estimation}`}>
|
||||
<EditableText
|
||||
editIconClass="is-size-4"
|
||||
render={(value) => (<h2 class="is-size-3">{value}</h2>)}
|
||||
onChange={onProjectLabelChange}
|
||||
value={project.label ? project.label : "Projet sans nom"} />
|
||||
<div class={style.tabContainer}>
|
||||
<Tabs items={[
|
||||
{
|
||||
label: 'Estimation',
|
||||
icon: '📋',
|
||||
render: () => <EstimationTab project={project} dispatch={dispatch} />
|
||||
},
|
||||
{
|
||||
label: 'Options avancées',
|
||||
icon: '⚙️',
|
||||
render: () => <ParamsTab project={project} dispatch={dispatch} />
|
||||
},
|
||||
{
|
||||
label: 'Exporter',
|
||||
icon: '↗️',
|
||||
render: () => <ExportTab project={project} />
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Project;
|
126
client/src/routes/project/params-tab.tsx
Normal file
126
client/src/routes/project/params-tab.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { FunctionalComponent, h, Fragment } from "preact";
|
||||
import { Project } from "../../models/project";
|
||||
import { ProjectReducerActions, updateParam } from "../../hooks/use-project-reducer";
|
||||
import { getRoundUpEstimations, getCurrency, getTimeUnit, getHideFinancialPreviewOnPrint } from "../../models/params";
|
||||
import TaskCategoriesTable from "./task-categories-table";
|
||||
import { useState } from "preact/hooks";
|
||||
import { route } from "preact-router";
|
||||
import { getProjectStorageKey } from "../../util/storage";
|
||||
|
||||
export interface ParamsTabProps {
|
||||
project: Project
|
||||
dispatch: (action: ProjectReducerActions) => void
|
||||
}
|
||||
|
||||
const ParamsTab: FunctionalComponent<ParamsTabProps> = ({ project, dispatch }) => {
|
||||
const [ deleteButtonEnabled, setDeleteButtonEnabled ] = useState(false);
|
||||
|
||||
const onEnableDeleteButtonChange = (evt: Event) => {
|
||||
const checked = (evt.currentTarget as HTMLInputElement).checked;
|
||||
setDeleteButtonEnabled(checked);
|
||||
}
|
||||
|
||||
const onRoundUpChange = (evt: Event) => {
|
||||
const checked = (evt.currentTarget as HTMLInputElement).checked;
|
||||
dispatch(updateParam("roundUpEstimations", checked));
|
||||
};
|
||||
|
||||
const onHideFinancialPreview = (evt: Event) => {
|
||||
const checked = (evt.currentTarget as HTMLInputElement).checked;
|
||||
dispatch(updateParam("hideFinancialPreviewOnPrint", checked));
|
||||
};
|
||||
|
||||
const onCurrencyChange = (evt: Event) => {
|
||||
const value = (evt.currentTarget as HTMLInputElement).value;
|
||||
dispatch(updateParam("currency", value));
|
||||
};
|
||||
|
||||
const timeUnit = getTimeUnit(project);
|
||||
|
||||
const onTimeUnitLabelChange = (evt: Event) => {
|
||||
const value = (evt.currentTarget as HTMLInputElement).value;
|
||||
dispatch(updateParam("timeUnit", { ...timeUnit, label: value }));
|
||||
};
|
||||
|
||||
const onTimeUnitAcronymChange = (evt: Event) => {
|
||||
const value = (evt.currentTarget as HTMLInputElement).value;
|
||||
dispatch(updateParam("timeUnit", { ...timeUnit, acronym: value }));
|
||||
};
|
||||
|
||||
const onDeleteProjectClick = (evt: Event) => {
|
||||
const projectStorageKey = getProjectStorageKey(project.id);
|
||||
window.localStorage.removeItem(projectStorageKey);
|
||||
route('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<label class="label is-size-5">Impression</label>
|
||||
<div class="field">
|
||||
<input type="checkbox"
|
||||
id="hideFinancialPreview"
|
||||
name="hideFinancialPreview"
|
||||
class="switch"
|
||||
onChange={onHideFinancialPreview}
|
||||
checked={getHideFinancialPreviewOnPrint(project)} />
|
||||
<label for="hideFinancialPreview">Cacher le prévisionnel financier lors de l'impression</label>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="field">
|
||||
<label class="label is-size-5">Unité de temps</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text"
|
||||
onChange={onTimeUnitLabelChange}
|
||||
value={timeUnit.label} />
|
||||
</div>
|
||||
<label class="label is-size-6">Acronyme</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text"
|
||||
onChange={onTimeUnitAcronymChange}
|
||||
value={timeUnit.acronym} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<input type="checkbox"
|
||||
id="roundUpEstimations"
|
||||
name="roundUpEstimations"
|
||||
class="switch"
|
||||
onChange={onRoundUpChange}
|
||||
checked={getRoundUpEstimations(project)} />
|
||||
<label for="roundUpEstimations">Arrondir les estimations de temps à l'entier supérieur</label>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="field">
|
||||
<label class="label is-size-5">Devise</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text"
|
||||
onChange={onCurrencyChange}
|
||||
value={getCurrency(project)} />
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<TaskCategoriesTable project={project} dispatch={dispatch} />
|
||||
<hr />
|
||||
<div>
|
||||
<label class="label is-size-5">Supprimer le projet</label>
|
||||
<div class="field">
|
||||
<input type="checkbox"
|
||||
id="enableDeleteButton"
|
||||
name="enableDeleteButton"
|
||||
class="switch is-warning"
|
||||
onChange={onEnableDeleteButtonChange}
|
||||
checked={deleteButtonEnabled} />
|
||||
<label for="enableDeleteButton">Supprimer ce projet ?</label>
|
||||
</div>
|
||||
<button class="button is-danger"
|
||||
onClick={onDeleteProjectClick}
|
||||
disabled={!deleteButtonEnabled}>
|
||||
🗑️ Supprimer
|
||||
</button>
|
||||
</div>
|
||||
<hr />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParamsTab;
|
44
client/src/routes/project/repartition-preview.tsx
Normal file
44
client/src/routes/project/repartition-preview.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { FunctionalComponent, h } from "preact";
|
||||
import { Project } from "../../models/project";
|
||||
import { useProjectEstimations } from "../../hooks/use-project-estimations";
|
||||
import { getCurrency, getRoundUpEstimations } from "../../models/params";
|
||||
import ProjectTimeUnit from "../../components/project-time-unit";
|
||||
import { getTaskCategoryWeightedMean, getProjectWeightedMean, getTaskCategoriesMeanRepartition } from "../../util/stat";
|
||||
|
||||
export interface RepartitionPreviewProps {
|
||||
project: Project
|
||||
}
|
||||
|
||||
const RepartitionPreview: FunctionalComponent<RepartitionPreviewProps> = ({ project }) => {
|
||||
const repartition = getTaskCategoriesMeanRepartition(project);
|
||||
return (
|
||||
<div class="table-container">
|
||||
<table class="table is-bordered is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={2}>Répartition moyenne</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Catégorie</th>
|
||||
<th>Temps (en %)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
Object.values(project.params.taskCategories).map(tc => {
|
||||
let percent = (repartition[tc.id] * 100).toFixed(0);
|
||||
return (
|
||||
<tr key={`task-category-${tc.id}`}>
|
||||
<td>{tc.label}</td>
|
||||
<td>{percent} %</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RepartitionPreview;
|
24
client/src/routes/project/style.module.css
Normal file
24
client/src/routes/project/style.module.css
Normal file
@ -0,0 +1,24 @@
|
||||
.estimation {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.noTasks {
|
||||
text-align: center !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.noBorder {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.mainColumn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.middleTable td {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.tabContainer {
|
||||
padding-top: 1em;
|
||||
}
|
17
client/src/routes/project/style.module.css.d.ts
vendored
Normal file
17
client/src/routes/project/style.module.css.d.ts
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
declare namespace StyleModuleCssModule {
|
||||
export interface IStyleModuleCss {
|
||||
estimation: string;
|
||||
mainColumn: string;
|
||||
middleTable: string;
|
||||
noBorder: string;
|
||||
noTasks: string;
|
||||
tabContainer: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare const StyleModuleCssModule: StyleModuleCssModule.IStyleModuleCss & {
|
||||
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
|
||||
locals: StyleModuleCssModule.IStyleModuleCss;
|
||||
};
|
||||
|
||||
export = StyleModuleCssModule;
|
113
client/src/routes/project/task-categories-table.tsx
Normal file
113
client/src/routes/project/task-categories-table.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { FunctionalComponent, h } from "preact";
|
||||
import { Project } from "../../models/project";
|
||||
import style from './style.module.css';
|
||||
import { ProjectReducerActions, updateTaskCategoryCost, updateTaskCategoryLabel, removeTaskCategory, addTaskCategory } from "../../hooks/use-project-reducer";
|
||||
import EditableText from "../../components/editable-text";
|
||||
import { TaskCategoryID, createTaskCategory } from "../../models/task";
|
||||
import { getCurrency, getTaskCategoryCost } from "../../models/params";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
export interface TaskCategoriesTableProps {
|
||||
project: Project
|
||||
dispatch: (action: ProjectReducerActions) => void
|
||||
}
|
||||
|
||||
const TaskCategoriesTable: FunctionalComponent<TaskCategoriesTableProps> = ({ project, dispatch }) => {
|
||||
const [ newTaskCategory, setNewTaskCategory ] = useState(createTaskCategory());
|
||||
|
||||
const onTaskCategoryRemove = (categoryId: TaskCategoryID) => {
|
||||
dispatch(removeTaskCategory(categoryId));
|
||||
};
|
||||
|
||||
const onTaskCategoryLabelChange = (categoryId: TaskCategoryID, value: string) => {
|
||||
dispatch(updateTaskCategoryLabel(categoryId, value));
|
||||
};
|
||||
|
||||
const onTaskCategoryCostChange = (categoryId: TaskCategoryID, value: string) => {
|
||||
const cost = parseFloat(value);
|
||||
dispatch(updateTaskCategoryCost(categoryId, cost));
|
||||
};
|
||||
|
||||
const onNewTaskCategoryCostChange = (evt: Event) => {
|
||||
const costPerTimeUnit = parseFloat((evt.currentTarget as HTMLInputElement).value);
|
||||
setNewTaskCategory(newTaskCategory => ({ ...newTaskCategory, costPerTimeUnit }));
|
||||
};
|
||||
|
||||
const onNewTaskCategoryLabelChange = (evt: Event) => {
|
||||
const label = (evt.currentTarget as HTMLInputElement).value;
|
||||
setNewTaskCategory(newTaskCategory => ({ ...newTaskCategory, label }));
|
||||
};
|
||||
|
||||
const onNewTaskCategoryAddClick = (evt: Event) => {
|
||||
dispatch(addTaskCategory(newTaskCategory));
|
||||
setNewTaskCategory(createTaskCategory());
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="table-container">
|
||||
<label class="label is-size-5">Catégories de tâche</label>
|
||||
<table class={`table is-bordered is-striped" ${style.middleTable}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class={`${style.noBorder} is-narrow`}></th>
|
||||
<th>Catégorie</th>
|
||||
<th>Coût par unité de temps</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
Object.values(project.params.taskCategories).map(tc => {
|
||||
return (
|
||||
<tr key={`task-category-${tc.id}`}>
|
||||
<td>
|
||||
<button
|
||||
onClick={onTaskCategoryRemove.bind(null, tc.id)}
|
||||
class="button is-danger is-small is-outlined">
|
||||
🗑️
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<EditableText value={tc.label}
|
||||
onChange={onTaskCategoryLabelChange.bind(null, tc.id)} />
|
||||
</td>
|
||||
<td>
|
||||
<EditableText value={`${getTaskCategoryCost(tc)}`}
|
||||
render={value=> (<span>{value} {getCurrency(project)}</span>)}
|
||||
onChange={onTaskCategoryCostChange.bind(null, tc.id)} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td class={`${style.noBorder}`}></td>
|
||||
<td colSpan={2}>
|
||||
<div class="field has-addons">
|
||||
<p class="control is-expanded">
|
||||
<input class="input" type="text" placeholder="Nouvelle catégorie"
|
||||
value={newTaskCategory.label} onChange={onNewTaskCategoryLabelChange} />
|
||||
</p>
|
||||
<p class="control">
|
||||
<input class="input" type="number"
|
||||
value={newTaskCategory.costPerTimeUnit} onChange={onNewTaskCategoryCostChange} />
|
||||
</p>
|
||||
<p class="control">
|
||||
<a class="button is-static">{getCurrency(project)}</a>
|
||||
</p>
|
||||
<p class="control">
|
||||
<a class="button is-primary" onClick={onNewTaskCategoryAddClick}>
|
||||
Ajouter
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskCategoriesTable;
|
200
client/src/routes/project/tasks-table.tsx
Normal file
200
client/src/routes/project/tasks-table.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import { FunctionalComponent, h } from "preact";
|
||||
import { useState, useEffect } from "preact/hooks";
|
||||
import style from "./style.module.css";
|
||||
import { Project } from "../../models/project";
|
||||
import { newTask, Task, TaskID, EstimationConfidence } from "../../models/task";
|
||||
import EditableText from "../../components/editable-text";
|
||||
import { usePrintMediaQuery } from "../../hooks/use-media-query";
|
||||
import { defaults, getTimeUnit } from "../../models/params";
|
||||
import ProjectTimeUnit from "../../components/project-time-unit";
|
||||
|
||||
export interface TaskTableProps {
|
||||
project: Project
|
||||
onTaskAdd: (task: Task) => void
|
||||
onTaskRemove: (taskId: TaskID) => void
|
||||
onEstimationChange: (taskId: TaskID, confidence: EstimationConfidence, value: number) => void
|
||||
onTaskLabelUpdate: (taskId: TaskID, label: string) => void
|
||||
}
|
||||
|
||||
export type EstimationTotals = { [confidence in EstimationConfidence]: number }
|
||||
|
||||
const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, onEstimationChange, onTaskRemove, onTaskLabelUpdate }) => {
|
||||
|
||||
const defaultTaskCategory = Object.keys(project.params.taskCategories)[0];
|
||||
const [ task, setTask ] = useState(newTask("", defaultTaskCategory));
|
||||
const [ totals, setTotals ] = useState({
|
||||
[EstimationConfidence.Optimistic]: 0,
|
||||
[EstimationConfidence.Likely]: 0,
|
||||
[EstimationConfidence.Pessimistic]: 0,
|
||||
} as EstimationTotals);
|
||||
|
||||
const isPrint = usePrintMediaQuery();
|
||||
|
||||
useEffect(() => {
|
||||
let optimistic = 0;
|
||||
let likely = 0;
|
||||
let pessimistic = 0;
|
||||
|
||||
Object.values(project.tasks).forEach(t => {
|
||||
optimistic += t.estimations.optimistic;
|
||||
likely += t.estimations.likely;
|
||||
pessimistic += t.estimations.pessimistic;
|
||||
});
|
||||
|
||||
setTotals({ optimistic, likely, pessimistic });
|
||||
}, [project.tasks]);
|
||||
|
||||
const onNewTaskLabelChange = (evt: Event) => {
|
||||
const value = (evt.currentTarget as HTMLInputElement).value;
|
||||
setTask({...task, label: value});
|
||||
};
|
||||
|
||||
const onNewTaskCategoryChange = (evt: Event) => {
|
||||
const value = (evt.currentTarget as HTMLInputElement).value;
|
||||
setTask({...task, category: value});
|
||||
};
|
||||
|
||||
const onTaskLabelChange = (taskId: TaskID, value: string) => {
|
||||
onTaskLabelUpdate(taskId, value);
|
||||
};
|
||||
|
||||
const onAddTaskClick = (evt: Event) => {
|
||||
onTaskAdd(task);
|
||||
setTask(newTask("", defaultTaskCategory));
|
||||
};
|
||||
|
||||
const onTaskRemoveClick = (taskId: TaskID, evt: Event) => {
|
||||
onTaskRemove(taskId);
|
||||
};
|
||||
|
||||
const withEstimationChange = (confidence: EstimationConfidence, taskID: TaskID, evt: Event) => {
|
||||
const textValue = (evt.currentTarget as HTMLInputElement).value;
|
||||
const value = parseFloat(textValue);
|
||||
onEstimationChange(taskID, confidence, value);
|
||||
};
|
||||
|
||||
const onOptimisticChange = withEstimationChange.bind(null, EstimationConfidence.Optimistic);
|
||||
const onLikelyChange = withEstimationChange.bind(null, EstimationConfidence.Likely);
|
||||
const onPessimisticChange = withEstimationChange.bind(null, EstimationConfidence.Pessimistic);
|
||||
|
||||
return (
|
||||
<div class="table-container">
|
||||
<table class={`table is-bordered is-striped is-hoverable is-fullwidth ${style.middleTable}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class={`${style.noBorder} noPrint`} rowSpan={2}></th>
|
||||
<th class={style.mainColumn} rowSpan={2}>Tâche</th>
|
||||
<th rowSpan={2}>Catégorie</th>
|
||||
<th colSpan={3}>Estimation (en <ProjectTimeUnit project={project} />)</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Optimiste</th>
|
||||
<th>Probable</th>
|
||||
<th>Pessimiste</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
Object.values(project.tasks).map(t => {
|
||||
const category = project.params.taskCategories[t.category];
|
||||
const categoryLabel = category ? category.label : '???';
|
||||
return (
|
||||
<tr key={`taks-${t.id}`}>
|
||||
<td class={`is-narrow noPrint`}>
|
||||
<button
|
||||
onClick={onTaskRemoveClick.bind(null, t.id)}
|
||||
class="button is-danger is-small is-outlined">
|
||||
🗑️
|
||||
</button>
|
||||
</td>
|
||||
<td class={style.mainColumn}>
|
||||
<EditableText
|
||||
render={(value) => (<span>{value}</span>)}
|
||||
onChange={onTaskLabelChange.bind(null, t.id)}
|
||||
value={t.label} />
|
||||
</td>
|
||||
<td>{ categoryLabel }</td>
|
||||
<td>
|
||||
{
|
||||
isPrint ?
|
||||
<span>{t.estimations.optimistic}</span> :
|
||||
<input class="input" type="number" value={t.estimations.optimistic}
|
||||
min={0}
|
||||
onChange={onOptimisticChange.bind(null, t.id)} />
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{
|
||||
isPrint ?
|
||||
<span>{t.estimations.likely}</span> :
|
||||
<input class="input" type="number" value={t.estimations.likely}
|
||||
min={0}
|
||||
onChange={onLikelyChange.bind(null, t.id)} />
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{
|
||||
isPrint ?
|
||||
<span>{t.estimations.pessimistic}</span> :
|
||||
<input class="input" type="number" value={t.estimations.pessimistic}
|
||||
min={0}
|
||||
onChange={onPessimisticChange.bind(null, t.id)} />
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
}
|
||||
{
|
||||
Object.keys(project.tasks).length === 0 ?
|
||||
<tr>
|
||||
<td class={`${style.noBorder} noPrint`}></td>
|
||||
<td class={style.noTasks} colSpan={5}>Aucune tâche pour l'instant.</td>
|
||||
</tr> :
|
||||
null
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td class={`${style.noBorder} noPrint`}></td>
|
||||
<td colSpan={2} class={isPrint ? style.noBorder : ''}>
|
||||
<div class="field has-addons noPrint">
|
||||
<p class="control is-expanded">
|
||||
<input class="input" type="text" placeholder="Nouvelle tâche"
|
||||
value={task.label} onChange={onNewTaskLabelChange} />
|
||||
</p>
|
||||
<p class="control">
|
||||
<span class="select">
|
||||
<select onChange={onNewTaskCategoryChange} value={task.category}>
|
||||
{
|
||||
Object.values(project.params.taskCategories).map(tc => {
|
||||
return (
|
||||
<option key={`task-category-${tc.id}`} value={tc.id}>{tc.label}</option>
|
||||
);
|
||||
})
|
||||
}
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
<p class="control">
|
||||
<a class="button is-primary" onClick={onAddTaskClick}>
|
||||
Ajouter
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<th colSpan={3}>Total</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={isPrint ? 2 : 3} class={style.noBorder}></td>
|
||||
<td>{totals.optimistic} <ProjectTimeUnit project={project} /></td>
|
||||
<td>{totals.likely} <ProjectTimeUnit project={project} /></td>
|
||||
<td>{totals.pessimistic} <ProjectTimeUnit project={project} /></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskTable;
|
50
client/src/routes/project/time-preview.tsx
Normal file
50
client/src/routes/project/time-preview.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { FunctionalComponent, h, Fragment } from "preact";
|
||||
import { Project } from "../../models/project";
|
||||
import { useProjectEstimations, Estimation } from "../../hooks/use-project-estimations";
|
||||
import EstimationRange from "../../components/estimation-range";
|
||||
|
||||
export interface TimePreviewProps {
|
||||
project: Project
|
||||
}
|
||||
|
||||
const TimePreview: FunctionalComponent<TimePreviewProps> = ({ project }) => {
|
||||
const estimations = useProjectEstimations(project);
|
||||
return (
|
||||
<div class="table-container">
|
||||
<table class="table is-bordered is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={2}>Prévisionnel temps</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="is-narrow">Confiance</th>
|
||||
<th>Estimation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="is-narrow">>= 99.7%</td>
|
||||
<td><EstimationRange project={project} estimation={estimations.p99} /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="is-narrow">>= 90%</td>
|
||||
<td><EstimationRange project={project} estimation={estimations.p90} /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="is-narrow">>= 68%</td>
|
||||
<td><EstimationRange project={project} estimation={estimations.p68} /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot class="noPrint">
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<a class="is-small is-pulled-right" href="https://en.wikipedia.org/wiki/Three-point_estimation" target="_blank">❓ Estimation à 3 points</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimePreview;
|
Reference in New Issue
Block a user