Project parameters edition

This commit is contained in:
wpetit 2020-04-21 20:45:47 +02:00
parent 69867b113f
commit b0339d2ce0
15 changed files with 327 additions and 22 deletions

5
package-lock.json generated
View File

@ -3148,6 +3148,11 @@
"resolved": "https://registry.npmjs.org/bulma/-/bulma-0.8.2.tgz", "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.8.2.tgz",
"integrity": "sha512-vMM/ijYSxX+Sm+nD7Lmc1UgWDy2JcL2nTKqwgEqXuOMU+IGALbXd5MLt/BcjBAPLIx36TtzhzBcSnOP974gcqA==" "integrity": "sha512-vMM/ijYSxX+Sm+nD7Lmc1UgWDy2JcL2nTKqwgEqXuOMU+IGALbXd5MLt/BcjBAPLIx36TtzhzBcSnOP974gcqA=="
}, },
"bulma-switch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/bulma-switch/-/bulma-switch-2.0.0.tgz",
"integrity": "sha512-myD38zeUfjmdduq+pXabhJEe3x2hQP48l/OI+Y0fO3HdDynZUY/VJygucvEAJKRjr4HxD5DnEm4yx+oDOBXpAA=="
},
"bytes": { "bytes": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",

View File

@ -26,6 +26,7 @@
"@types/bs58": "^4.0.1", "@types/bs58": "^4.0.1",
"bs58": "^4.0.1", "bs58": "^4.0.1",
"bulma": "^0.8.2", "bulma": "^0.8.2",
"bulma-switch": "^2.0.0",
"preact": "^10.3.1", "preact": "^10.3.1",
"preact-jsx-chai": "^3.0.0", "preact-jsx-chai": "^3.0.0",
"preact-markup": "^2.0.0", "preact-markup": "^2.0.0",

View File

@ -8,7 +8,7 @@ export interface EditableTextProps {
class?: string class?: string
editIconClass?: string editIconClass?: string
onChange?: (value: string) => void onChange?: (value: string) => void
render: (value: string) => ComponentChild render?: (value: string) => ComponentChild
} }
const EditableText: FunctionalComponent<EditableTextProps> = ({ onChange, value, render, ...props }) => { const EditableText: FunctionalComponent<EditableTextProps> = ({ onChange, value, render, ...props }) => {
@ -45,7 +45,7 @@ const EditableText: FunctionalComponent<EditableTextProps> = ({ onChange, value,
</div> </div>
</div> : </div> :
<Fragment> <Fragment>
{ render(internalValue) } { render ? render(internalValue) : <span>{internalValue}</span> }
<i class={`${style.editIcon} icon ${props.editIconClass ? props.editIconClass : ''}`} onClick={onEditIconClick}>🖋</i> <i class={`${style.editIcon} icon ${props.editIconClass ? props.editIconClass : ''}`} onClick={onEditIconClick}>🖋</i>
</Fragment> </Fragment>
} }

View File

@ -1,5 +1,5 @@
.editableText { .editableText {
display: inherit; display: inline-block;
} }
.editableText > * { .editableText > * {

View File

@ -9,13 +9,17 @@ export interface EstimationRangeProps {
export const EstimationRange: FunctionalComponent<EstimationRangeProps> = ({ project, estimation, sdFactor }) => { export const EstimationRange: FunctionalComponent<EstimationRangeProps> = ({ project, estimation, sdFactor }) => {
const roundUp = getRoundUpEstimations(project); const roundUp = getRoundUpEstimations(project);
const e = roundUp ? Math.ceil(estimation.e) : estimation.e; let e = roundUp ? Math.ceil(estimation.e) : estimation.e;
const sd = roundUp ? Math.ceil(estimation.sd * sdFactor) : (estimation.sd * sdFactor); let sd = roundUp ? Math.ceil(estimation.sd * sdFactor) : (estimation.sd * sdFactor);
const max = e+sd; const max = e+sd;
const min = Math.max(e-sd, 0); const min = Math.max(e-sd, 0);
if (!roundUp) {
sd = sd.toFixed(2);
e = e.toFixed(2);
}
return ( return (
<Fragment> <Fragment>
<abbr title={`max: ${max}, min: ${min}`}>{`${e} ± ${sd}`}</abbr>&nbsp;<ProjectTimeUnit project={project} /> <abbr title={`max: ${max.toFixed(2)}, min: ${min.toFixed(2)}`}>{`${e} ± ${sd}`}</abbr>&nbsp;<ProjectTimeUnit project={project} />
</Fragment> </Fragment>
); );

View File

@ -1,5 +1,5 @@
import { Project } from "../models/project"; import { Project } from "../models/project";
import { Task, TaskID, EstimationConfidence } from "../models/task"; import { Task, TaskID, EstimationConfidence, TaskCategoryID, TaskCategory } from "../models/task";
import { useReducer } from "preact/hooks"; import { useReducer } from "preact/hooks";
export interface Action { export interface Action {
@ -11,13 +11,19 @@ export type ProjectReducerActions =
RemoveTask | RemoveTask |
UpdateTaskEstimation | UpdateTaskEstimation |
UpdateProjectLabel | UpdateProjectLabel |
UpdateTaskLabel UpdateTaskLabel |
UpdateParam |
UpdateTaskCategoryLabel |
UpdateTaskCategoryCost |
AddTaskCategory |
RemoveTaskCategory
export function useProjectReducer(project: Project) { export function useProjectReducer(project: Project) {
return useReducer(projectReducer, project); return useReducer(projectReducer, project);
} }
export function projectReducer(project: Project, action: ProjectReducerActions): Project { export function projectReducer(project: Project, action: ProjectReducerActions): Project {
console.log(action);
switch(action.type) { switch(action.type) {
case ADD_TASK: case ADD_TASK:
return handleAddTask(project, action as AddTask); return handleAddTask(project, action as AddTask);
@ -33,6 +39,21 @@ export function projectReducer(project: Project, action: ProjectReducerActions):
case UPDATE_TASK_LABEL: case UPDATE_TASK_LABEL:
return handleUpdateTaskLabel(project, action as UpdateTaskLabel); return handleUpdateTaskLabel(project, action as UpdateTaskLabel);
case UPDATE_PARAM:
return handleUpdateParam(project, action as UpdateParam);
case ADD_TASK_CATEGORY:
return handleAddTaskCategory(project, action as AddTaskCategory);
case REMOVE_TASK_CATEGORY:
return handleRemoveTaskCategory(project, action as RemoveTaskCategory);
case UPDATE_TASK_CATEGORY_LABEL:
return handleUpdateTaskCategoryLabel(project, action as UpdateTaskCategoryLabel);
case UPDATE_TASK_CATEGORY_COST:
return handleUpdateTaskCategoryCost(project, action as UpdateTaskCategoryCost);
} }
return project; return project;
@ -157,3 +178,124 @@ export function handleUpdateTaskLabel(project: Project, action: UpdateTaskLabel)
} }
}; };
} }
export interface UpdateParam extends Action {
name: string
value: any
}
export const UPDATE_PARAM = "UPDATE_PARAM";
export function updateParam(name: string, value: any): UpdateParam {
return { type: UPDATE_PARAM, name, value };
}
export function handleUpdateParam(project: Project, action: UpdateParam): Project {
return {
...project,
params: {
...project.params,
[action.name]: action.value,
}
};
}
export interface UpdateTaskCategoryLabel extends Action {
categoryId: TaskCategoryID
label: string
}
export const UPDATE_TASK_CATEGORY_LABEL = "UPDATE_TASK_CATEGORY_LABEL";
export function updateTaskCategoryLabel(categoryId: TaskCategoryID, label: string): UpdateTaskCategoryLabel {
return { type: UPDATE_TASK_CATEGORY_LABEL, categoryId, label };
}
export function handleUpdateTaskCategoryLabel(project: Project, action: UpdateTaskCategoryLabel): Project {
return {
...project,
params: {
...project.params,
taskCategories: {
...project.params.taskCategories,
[action.categoryId]: {
...project.params.taskCategories[action.categoryId],
label: action.label
},
}
}
};
}
export interface UpdateTaskCategoryCost extends Action {
categoryId: TaskCategoryID
costPerTimeUnit: number
}
export const UPDATE_TASK_CATEGORY_COST = "UPDATE_TASK_CATEGORY_COST";
export function updateTaskCategoryCost(categoryId: TaskCategoryID, costPerTimeUnit: number): UpdateTaskCategoryCost {
return { type: UPDATE_TASK_CATEGORY_COST, categoryId, costPerTimeUnit };
}
export function handleUpdateTaskCategoryCost(project: Project, action: UpdateTaskCategoryCost): Project {
return {
...project,
params: {
...project.params,
taskCategories: {
...project.params.taskCategories,
[action.categoryId]: {
...project.params.taskCategories[action.categoryId],
costPerTimeUnit: action.costPerTimeUnit
},
}
}
};
}
export const ADD_TASK_CATEGORY = "ADD_TASK_CATEGORY";
export interface AddTaskCategory extends Action {
taskCategory: TaskCategory
}
export function addTaskCategory(taskCategory: TaskCategory): AddTaskCategory {
return { type: ADD_TASK_CATEGORY, taskCategory };
}
export function handleAddTaskCategory(project: Project, action: AddTaskCategory): Project {
const taskCategory = { ...action.taskCategory };
return {
...project,
params: {
...project.params,
taskCategories: {
...project.params.taskCategories,
[taskCategory.id]: taskCategory,
}
}
};
}
export interface RemoveTaskCategory extends Action {
taskCategoryId: TaskCategoryID
}
export const REMOVE_TASK_CATEGORY = "REMOVE_TASK_CATEGORY";
export function removeTaskCategory(taskCategoryId: TaskCategoryID): RemoveTaskCategory {
return { type: REMOVE_TASK_CATEGORY, taskCategoryId };
}
export function handleRemoveTaskCategory(project: Project, action: RemoveTaskCategory): Project {
const taskCategories = { ...project.params.taskCategories };
delete taskCategories[action.taskCategoryId];
return {
...project,
params: {
...project.params,
taskCategories
}
};
}

View File

@ -1,5 +1,6 @@
import "./style/index.css"; import "./style/index.css";
import "bulma/css/bulma.css"; import "bulma/css/bulma.css";
import "bulma-switch/dist/css/bulma-switch.min.css";
import App from "./components/app.tsx"; import App from "./components/app.tsx";

View File

@ -1,4 +1,4 @@
import { TaskCategory, CategoryID } from "./task"; import { TaskCategory, TaskCategoryID } from "./task";
import { Project } from "./project"; import { Project } from "./project";
export interface TaskCategoriesIndex { export interface TaskCategoriesIndex {
@ -41,6 +41,7 @@ export const defaults = {
}, },
roundUpEstimations: true, roundUpEstimations: true,
currency: "€ H.T.", currency: "€ H.T.",
costPerTimeUnit: 500,
} }
export function getTimeUnit(project: Project): TimeUnit { export function getTimeUnit(project: Project): TimeUnit {
@ -58,3 +59,7 @@ export function getCurrency(project: Project): string {
export function getTaskCategories(project: Project): TaskCategoriesIndex { export function getTaskCategories(project: Project): TaskCategoriesIndex {
return project.params.taskCategories ? project.params.taskCategories : defaults.taskCategories; return project.params.taskCategories ? project.params.taskCategories : defaults.taskCategories;
} }
export function getTaskCategoryCost(taskCategory: TaskCategory): number {
return taskCategory.hasOwnProperty("costPerTimeUnit") ? taskCategory.costPerTimeUnit : defaults.costPerTimeUnit;
}

View File

@ -24,12 +24,9 @@ export function newProject(id?: string): Project {
tasks: {}, tasks: {},
params: { params: {
taskCategories: defaults.taskCategories, taskCategories: defaults.taskCategories,
currency: "€", currency: defaults.currency,
roundUpEstimations: true, roundUpEstimations: defaults.roundUpEstimations,
timeUnit: { timeUnit: defaults.timeUnit,
label: "Jour/homme",
acronym: "j/h",
}
}, },
}; };
} }

View File

@ -11,19 +11,19 @@ export enum EstimationConfidence {
export interface Task { export interface Task {
id: TaskID id: TaskID
label: string label: string
category: CategoryID category: TaskCategoryID
estimations: { [confidence in EstimationConfidence]: number } estimations: { [confidence in EstimationConfidence]: number }
} }
export type CategoryID = string export type TaskCategoryID = string
export interface TaskCategory { export interface TaskCategory {
id: CategoryID id: TaskCategoryID
label: string label: string
costPerTimeUnit: number costPerTimeUnit: number
} }
export function newTask(label: string, category: CategoryID): Task { export function newTask(label: string, category: TaskCategoryID): Task {
return { return {
id: uuidV4(), id: uuidV4(),
label, label,

View File

@ -49,7 +49,7 @@ const EstimationTab: FunctionalComponent<EstimationTabProps> = ({ project, dispa
<div class="message noPrint"> <div class="message noPrint">
<div class="message-body"> <div class="message-body">
<p><strong> Attention</strong></p> <p><strong> Attention</strong></p>
<p>Votre projet ne contient pas assez de tâches actuellement pour que les niveaux de confiance soient fiables. Un minimum de 20 tâches est conseillé pour obtenir une estimation pertinente.</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>
</div> : </div> :
null null

View File

@ -8,6 +8,7 @@ import { useLocalStorage } from "../../hooks/use-local-storage";
import EditableText from "../../components/editable-text"; import EditableText from "../../components/editable-text";
import Tabs from "../../components/tabs"; import Tabs from "../../components/tabs";
import EstimationTab from "./estimation-tab"; import EstimationTab from "./estimation-tab";
import ParamsTab from "./params-tab";
export interface ProjectProps { export interface ProjectProps {
projectId: string projectId: string
@ -44,7 +45,7 @@ const Project: FunctionalComponent<ProjectProps> = ({ projectId }) => {
{ {
label: 'Paramètres', label: 'Paramètres',
icon: '⚙️', icon: '⚙️',
render: () => null render: () => <ParamsTab project={project} dispatch={dispatch} />
}, },
{ {
label: 'Exporter', label: 'Exporter',

View File

@ -0,0 +1,79 @@
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, updateParam } from "../../hooks/use-project-reducer";
import { getRoundUpEstimations, getCurrency, getTimeUnit } from "../../models/params";
import TaskCategoriesTable from "./task-categories-table";
export interface ParamsTabProps {
project: Project
dispatch: (action: ProjectReducerActions) => void
}
const ParamsTab: FunctionalComponent<ParamsTabProps> = ({ project, dispatch }) => {
const onRoundUpChange = (evt: Event) => {
const checked = (evt.currentTarget as HTMLInputElement).checked;
dispatch(updateParam("roundUpEstimations", 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 }));
};
return (
<Fragment>
<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">Unité de temps</label>
<div class="control">
<input class="input" type="text"
onChange={onTimeUnitLabelChange}
value={timeUnit.label} />
</div>
<label class="label">Acronyme</label>
<div class="control">
<input class="input" type="text"
onChange={onTimeUnitAcronymChange}
value={timeUnit.acronym} />
</div>
</div>
<hr />
<div class="field">
<label class="label">Devise</label>
<div class="control">
<input class="input" type="text"
onChange={onCurrencyChange}
value={getCurrency(project)} />
</div>
</div>
<hr />
<TaskCategoriesTable project={project} dispatch={dispatch} />
</Fragment>
);
};
export default ParamsTab;

View File

@ -0,0 +1,70 @@
import { FunctionalComponent, h } from "preact";
import { Project } from "../../models/project";
import * as style from './style.css';
import { projectReducer, ProjectReducerActions, updateTaskCategoryCost, updateTaskCategoryLabel, removeTaskCategory } from "../../hooks/use-project-reducer";
import EditableText from "../../components/editable-text";
import { TaskCategoryID } from "../../models/task";
import ProjectTimeUnit from "../../components/project-time-unit";
import { getCurrency, getTaskCategoryCost } from "../../models/params";
export interface TaskCategoriesTableProps {
project: Project
dispatch: (action: ProjectReducerActions) => void
}
const TaskCategoriesTable: FunctionalComponent<TaskCategoriesTableProps> = ({ project, dispatch }) => {
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));
};
return (
<div class="table-container">
<label class="label">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>
</table>
</div>
);
};
export default TaskCategoriesTable;

View File

@ -85,7 +85,7 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
<th class={`${style.noBorder} noPrint`} rowSpan={2}></th> <th class={`${style.noBorder} noPrint`} rowSpan={2}></th>
<th class={style.mainColumn} rowSpan={2}>Tâche</th> <th class={style.mainColumn} rowSpan={2}>Tâche</th>
<th rowSpan={2}>Catégorie</th> <th rowSpan={2}>Catégorie</th>
<th colSpan={3}>Estimation (en <ProjectTimeUnit project={project} />)</th> <th colSpan={3}>Estimation (en <ProjectTimeUnit project={project} />)</th>
</tr> </tr>
<tr> <tr>
<th>Optimiste</th> <th>Optimiste</th>