feat(ui): integrate v1 editor
This commit is contained in:
parent
f55bce9ee2
commit
c7cea6e46b
|
@ -1775,6 +1775,52 @@
|
|||
"resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.1.0.tgz",
|
||||
"integrity": "sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg=="
|
||||
},
|
||||
"@teamsupercell/typings-for-css-modules-loader": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@teamsupercell/typings-for-css-modules-loader/-/typings-for-css-modules-loader-2.2.1.tgz",
|
||||
"integrity": "sha512-Pv+OXrZAVwTMhUUD5NkbjNsxNSzzWrtqwyJ3RQ6KONdlS5QBnR7RAU3ygt3XJykxjEknokZK0H47C3kjxSPTHA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"camelcase": "^5.3.1",
|
||||
"loader-utils": "1.2.3",
|
||||
"prettier": "*",
|
||||
"schema-utils": "^2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true
|
||||
},
|
||||
"emojis-list": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz",
|
||||
"integrity": "sha1-TapNnbAPmBmIDHn6RXrlsJof04k=",
|
||||
"dev": true
|
||||
},
|
||||
"json5": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"minimist": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"loader-utils": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.2.3.tgz",
|
||||
"integrity": "sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"big.js": "^5.2.2",
|
||||
"emojis-list": "^2.0.0",
|
||||
"json5": "^1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@types/anymatch": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz",
|
||||
|
@ -3782,6 +3828,11 @@
|
|||
"resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.0.tgz",
|
||||
"integrity": "sha512-rV75CJkubNUroAt0qCRkjznZLoaXq/ctfMXsMvKSL84UetbSyx5REl96e8GoQ04G4Tkw0XF3STECffTOQrbzOQ=="
|
||||
},
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
|
||||
|
@ -9156,6 +9207,13 @@
|
|||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz",
|
||||
"integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ=="
|
||||
},
|
||||
"prettier": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.0.5.tgz",
|
||||
"integrity": "sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"pretty-error": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-2.1.1.tgz",
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
"@commitlint/cli": "^9.1.2",
|
||||
"@commitlint/config-conventional": "^9.1.1",
|
||||
"@fortawesome/fontawesome-free": "^5.11.2",
|
||||
"@teamsupercell/typings-for-css-modules-loader": "^2.2.1",
|
||||
"@types/node": "^13.13.4",
|
||||
"@types/react-dom": "^16.9.7",
|
||||
"@types/react-redux": "^7.1.7",
|
||||
|
@ -56,6 +57,7 @@
|
|||
"@apollo/client": "^3.0.2",
|
||||
"@types/qs": "^6.9.3",
|
||||
"bulma": "^0.9.0",
|
||||
"bulma-switch": "^2.0.0",
|
||||
"graphql": "^15.3.0",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
|
|
|
@ -6,6 +6,7 @@ import { DashboardPage } from './DashboardPage/DashboardPage';
|
|||
import { PrivateRoute } from './PrivateRoute';
|
||||
import { useLoggedIn, LoggedInContext } from '../hooks/useLoggedIn';
|
||||
import { useUserProfile } from '../gql/queries/user';
|
||||
import { ProjectPage } from './ProjectPage/ProjectPage';
|
||||
|
||||
export interface AppProps {
|
||||
|
||||
|
@ -21,6 +22,7 @@ export const App: FunctionComponent<AppProps> = () => {
|
|||
<Route path="/" exact component={HomePage} />
|
||||
<PrivateRoute path="/profile" exact component={ProfilePage} />
|
||||
<PrivateRoute path="/dashboard" exact component={DashboardPage} />
|
||||
<PrivateRoute path="/projects/:id" exact component={ProjectPage} />
|
||||
<Route component={() => <Redirect to="/" />} />
|
||||
</Switch>
|
||||
</BrowserRouter>
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import React from 'react';
|
||||
import { EstimationPanel } from './EstimationPanel';
|
||||
import { ProjectPanel } from './ProjectPanel';
|
||||
import { ProjectModelPanel } from './ProjectModelPanel';
|
||||
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<div className="container is-fluid">
|
||||
<div className="columns">
|
||||
<div className="column is-4">
|
||||
<EstimationPanel />
|
||||
</div>
|
||||
<div className="column is-4">
|
||||
</div>
|
||||
<div className="column is-4">
|
||||
<div className="columns">
|
||||
<div className="column is-4">
|
||||
<ProjectPanel />
|
||||
</div>
|
||||
<div className="column is-4">
|
||||
<ProjectModelPanel />
|
||||
</div>
|
||||
<div className="column is-4">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
import React from 'react';
|
||||
import { Page } from '../Page';
|
||||
import { Dashboard } from './Dashboard';
|
||||
import { useUserProfile } from '../../gql/queries/user';
|
||||
|
||||
export function DashboardPage() {
|
||||
const { user } = useUserProfile();
|
||||
return (
|
||||
<Page title="Tableau de bord">
|
||||
<section className="mt-5">
|
||||
<Dashboard />
|
||||
<div className="container is-fluid">
|
||||
<h2 className="subtitle is-size-3">Bonjour <span>{user.name !== '' ? user.name : ' utilisateur mystère'}</span> ! Sur quel projet souhaitez vous travailler aujourd'hui ?</h2>
|
||||
<Dashboard />
|
||||
</div>
|
||||
</section>
|
||||
</Page>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { ItemPanel } from "./ItemPanel";
|
||||
|
||||
export interface ProjectModelPanelProps {
|
||||
|
||||
}
|
||||
|
||||
export const ProjectModelPanel: FunctionComponent<ProjectModelPanelProps> = () => {
|
||||
return (
|
||||
<ItemPanel
|
||||
title="Mes modèles de projet"
|
||||
className="is-info"
|
||||
newItemUrl="/models/new"
|
||||
items={[]}
|
||||
itemKey={(item) => { return item.id }}
|
||||
itemLabel={(item) => { return item.id }}
|
||||
itemUrl={(item) => { return `/models/${item.id}` }}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,20 +1,20 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { ItemPanel } from "./ItemPanel";
|
||||
|
||||
export interface EstimationPanelProps {
|
||||
export interface ProjectPanelProps {
|
||||
|
||||
}
|
||||
|
||||
export const EstimationPanel: FunctionComponent<EstimationPanelProps> = () => {
|
||||
export const ProjectPanel: FunctionComponent<ProjectPanelProps> = () => {
|
||||
return (
|
||||
<ItemPanel
|
||||
title="Mes estimations"
|
||||
title="Mes projets"
|
||||
className="is-primary"
|
||||
newItemUrl="/estimations/new"
|
||||
newItemUrl="/projects/new"
|
||||
items={[]}
|
||||
itemKey={(item) => { return item.id }}
|
||||
itemLabel={(item) => { return item.id }}
|
||||
itemUrl={(item) => { return `/estimations/${item.id}` }}
|
||||
itemUrl={(item) => { return `/projects/${item.id}` }}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,63 @@
|
|||
import React, {
|
||||
FunctionComponent, Fragment,
|
||||
ReactNode, ChangeEvent,
|
||||
useState, useEffect
|
||||
} from "react";
|
||||
import * as style from "./style.module.css";
|
||||
|
||||
export interface EditableTextProps {
|
||||
value: string
|
||||
class?: string
|
||||
editIconClass?: string
|
||||
onChange?: (value: string) => void
|
||||
render?: (value: string) => ReactNode
|
||||
}
|
||||
|
||||
const EditableText: FunctionComponent<EditableTextProps> = ({ onChange, value, render, ...props }) => {
|
||||
const [ internalValue, setInternalValue ] = useState(value);
|
||||
const [ editMode, setEditMode ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (internalValue === value) return;
|
||||
if (onChange) onChange(internalValue);
|
||||
}, [internalValue]);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(value);
|
||||
}, [value])
|
||||
|
||||
const onEditIconClick = () => {
|
||||
setEditMode(true);
|
||||
};
|
||||
|
||||
const onValidateButtonClick = () => {
|
||||
setEditMode(false);
|
||||
}
|
||||
|
||||
const onValueChange = (evt: ChangeEvent) => {
|
||||
const currentTarget = evt.currentTarget as HTMLInputElement;
|
||||
setInternalValue(currentTarget.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${style.editableText} ${props.class ? props.class : ''}`}>
|
||||
{
|
||||
editMode ?
|
||||
<div className="field has-addons">
|
||||
<div className="control">
|
||||
<input className="input is-expanded" type="text" value={internalValue} onChange={onValueChange} />
|
||||
</div>
|
||||
<div className="control">
|
||||
<a className="button" onClick={onValidateButtonClick}>✔️</a>
|
||||
</div>
|
||||
</div> :
|
||||
<Fragment>
|
||||
{ render ? render(internalValue) : <span>{internalValue}</span> }
|
||||
<i className={`${style.editIcon} icon ${props.editIconClass ? props.editIconClass : ''}`} onClick={onEditIconClick}>🖋️</i>
|
||||
</Fragment>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableText;
|
|
@ -0,0 +1,17 @@
|
|||
.editableText {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.editableText > * {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.editIcon {
|
||||
visibility: hidden;
|
||||
margin-left: 0.25em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editableText:hover > .editIcon {
|
||||
visibility: visible;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
declare namespace StyleModuleCssNamespace {
|
||||
export interface IStyleModuleCss {
|
||||
editIcon: string;
|
||||
editableText: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare const StyleModuleCssModule: StyleModuleCssNamespace.IStyleModuleCss & {
|
||||
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
|
||||
locals: StyleModuleCssNamespace.IStyleModuleCss;
|
||||
};
|
||||
|
||||
export = StyleModuleCssModule;
|
|
@ -0,0 +1,35 @@
|
|||
import ProjectTimeUnit from "./ProjectTimeUnit";
|
||||
import { getRoundUpEstimations } from "../types/params";
|
||||
import { Project } from "../types/project";
|
||||
import React, { Fragment,FunctionComponent } from "react";
|
||||
import { Estimation } from "../hooks/useProjectEstimations";
|
||||
|
||||
export interface EstimationRangeProps {
|
||||
project: Project,
|
||||
estimation: Estimation
|
||||
}
|
||||
|
||||
export const EstimationRange: FunctionComponent<EstimationRangeProps> = ({ project, estimation }) => {
|
||||
const roundUp = getRoundUpEstimations(project);
|
||||
let e: number|string = estimation.e;
|
||||
let sd: number|string = estimation.sd;
|
||||
let max = e+sd;
|
||||
let min = Math.max(e-sd, 0);
|
||||
if (roundUp) {
|
||||
sd = Math.ceil(sd);
|
||||
e = Math.ceil(e);
|
||||
max = Math.ceil(max);
|
||||
min = Math.ceil(min);
|
||||
} else {
|
||||
sd = sd.toFixed(2);
|
||||
e = e.toFixed(2);
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
<abbr title={`max: ${max.toFixed(2)}, min: ${min.toFixed(2)}`}>{`${e} ± ${sd}`}</abbr> <ProjectTimeUnit project={project} />
|
||||
</Fragment>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
export default EstimationRange;
|
|
@ -20,7 +20,7 @@ export function Navbar() {
|
|||
<Link className="navbar-item" to={loggedIn ? '/dashboard' : '/'}>
|
||||
<h1 className="is-size-4">
|
||||
<i className="fa fa-stopwatch mr-1"></i>
|
||||
Guesstimate
|
||||
<span className="has-text-dark">Guessti</span><span className="has-text-grey">Mate</span>
|
||||
</h1>
|
||||
</Link>
|
||||
<a role="button"
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
import React, { FunctionComponent, Fragment } from "react";
|
||||
import { Project } from "../../types/project";
|
||||
import TaskTable from "./TasksTable";
|
||||
import { TimePreview } from "./TimePreview";
|
||||
import FinancialPreview from "./FinancielPreview";
|
||||
import { addTask, updateTaskEstimation, removeTask, updateTaskLabel, ProjectReducerActions } from "../../hooks/useProjectReducer";
|
||||
import { Task, TaskID, EstimationConfidence } from "../../types/task";
|
||||
import RepartitionPreview from "./RepartitionPreview";
|
||||
import { getHideFinancialPreviewOnPrint } from "../../types/params";
|
||||
|
||||
export interface EstimationTabProps {
|
||||
project: Project
|
||||
dispatch: (action: ProjectReducerActions) => void
|
||||
}
|
||||
|
||||
const EstimationTab: FunctionComponent<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 className="columns">
|
||||
<div className="column is-9">
|
||||
<TaskTable
|
||||
project={project}
|
||||
onTaskAdd={onTaskAdd}
|
||||
onTaskRemove={onTaskRemove}
|
||||
onTaskLabelUpdate={onTaskLabelUpdate}
|
||||
onEstimationChange={onEstimationChange} />
|
||||
</div>
|
||||
<div className="column is-3">
|
||||
<TimePreview project={project} />
|
||||
<RepartitionPreview project={project} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="columns">
|
||||
<div className={`column ${getHideFinancialPreviewOnPrint(project) ? 'noPrint': ''}`}>
|
||||
<FinancialPreview project={project} />
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
Object.keys(project.tasks).length <= 20 ?
|
||||
<div className="message noPrint">
|
||||
<div className="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;
|
|
@ -0,0 +1,18 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { Project } from "../../types/project";
|
||||
|
||||
export interface ExportTabProps {
|
||||
project: Project
|
||||
}
|
||||
|
||||
const ExportTab: FunctionComponent<ExportTabProps> = ({ project }) => {
|
||||
return (
|
||||
<div>
|
||||
<label className="label is-size-4">Format JSON</label>
|
||||
<pre>{ JSON.stringify(project, null, 2) }</pre>
|
||||
<hr />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportTab;
|
|
@ -0,0 +1,84 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { Project, getMinMaxCosts, Cost } from "../../types/project";
|
||||
import { useProjectEstimations } from "../../hooks/useProjectEstimations";
|
||||
import { getCurrency, defaults, getTaskCategoryCost, getRoundUpEstimations } from "../../types/params";
|
||||
import * as style from './style.module.css';
|
||||
import ProjectTimeUnit from "../ProjectTimeUnit";
|
||||
|
||||
export interface FinancialPreviewProps {
|
||||
project: Project
|
||||
}
|
||||
|
||||
const FinancialPreview: FunctionComponent<FinancialPreviewProps> = ({ project }) => {
|
||||
const estimations = useProjectEstimations(project);
|
||||
const costs = getMinMaxCosts(project, estimations.p99);
|
||||
const roundUp = getRoundUpEstimations(project);
|
||||
return (
|
||||
<div className="table-container">
|
||||
<table className="table is-bordered is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={2}>
|
||||
<span>Prévisionnel financier</span><br />
|
||||
<span className="is-size-7 has-text-weight-normal">confiance >= 99.7%</span>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="is-narrow">Temps</th>
|
||||
<th>Coût</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="is-narrow">Maximum</td>
|
||||
<td>
|
||||
<CostDetails project={project} cost={costs.max} roundUp={roundUp} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="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:FunctionComponent<CostDetailsProps> = ({ project, cost, roundUp }) => {
|
||||
return (
|
||||
<details>
|
||||
<summary><strong>
|
||||
≈ {cost.totalCost} {getCurrency(project)}</strong>
|
||||
<span className="is-pulled-right">{ roundUp ? Math.ceil(cost.totalTime) : cost.totalTime.toFixed(2) } <ProjectTimeUnit project={project} /></span>
|
||||
</summary>
|
||||
<table className={`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 className={`${style.noBorder} is-size-6`}>{taskCategory.label}</td>
|
||||
<td className={`${style.noBorder} is-size-6`}>{details.cost} {getCurrency(project)}</td>
|
||||
<td className={`${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;
|
|
@ -0,0 +1,123 @@
|
|||
import React, { FunctionComponent, Fragment, useState, ChangeEvent, MouseEvent } from "react";
|
||||
import { Project } from "../../types/project";
|
||||
import { ProjectReducerActions, updateParam } from "../../hooks/useProjectReducer";
|
||||
import { getRoundUpEstimations, getCurrency, getTimeUnit, getHideFinancialPreviewOnPrint } from "../../types/params";
|
||||
import TaskCategoriesTable from "./TaskCategorieTable";
|
||||
import { useHistory } from "react-router";
|
||||
|
||||
export interface ParamsTabProps {
|
||||
project: Project
|
||||
dispatch: (action: ProjectReducerActions) => void
|
||||
}
|
||||
|
||||
const ParamsTab: FunctionComponent<ParamsTabProps> = ({ project, dispatch }) => {
|
||||
const [ deleteButtonEnabled, setDeleteButtonEnabled ] = useState(false);
|
||||
const history = useHistory();
|
||||
|
||||
const onEnableDeleteButtonChange = (evt: ChangeEvent) => {
|
||||
const checked = (evt.currentTarget as HTMLInputElement).checked;
|
||||
setDeleteButtonEnabled(checked);
|
||||
}
|
||||
|
||||
const onRoundUpChange = (evt: ChangeEvent) => {
|
||||
const checked = (evt.currentTarget as HTMLInputElement).checked;
|
||||
dispatch(updateParam("roundUpEstimations", checked));
|
||||
};
|
||||
|
||||
const onHideFinancialPreview = (evt: ChangeEvent) => {
|
||||
const checked = (evt.currentTarget as HTMLInputElement).checked;
|
||||
dispatch(updateParam("hideFinancialPreviewOnPrint", checked));
|
||||
};
|
||||
|
||||
const onCurrencyChange = (evt: ChangeEvent) => {
|
||||
const value = (evt.currentTarget as HTMLInputElement).value;
|
||||
dispatch(updateParam("currency", value));
|
||||
};
|
||||
|
||||
const timeUnit = getTimeUnit(project);
|
||||
|
||||
const onTimeUnitLabelChange = (evt: ChangeEvent) => {
|
||||
const value = (evt.currentTarget as HTMLInputElement).value;
|
||||
dispatch(updateParam("timeUnit", { ...timeUnit, label: value }));
|
||||
};
|
||||
|
||||
const onTimeUnitAcronymChange = (evt: ChangeEvent) => {
|
||||
const value = (evt.currentTarget as HTMLInputElement).value;
|
||||
dispatch(updateParam("timeUnit", { ...timeUnit, acronym: value }));
|
||||
};
|
||||
|
||||
const onDeleteProjectClick = (evt: MouseEvent) => {
|
||||
// TODO
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<label className="label is-size-5">Impression</label>
|
||||
<div className="field">
|
||||
<input type="checkbox"
|
||||
id="hideFinancialPreview"
|
||||
name="hideFinancialPreview"
|
||||
className="switch"
|
||||
onChange={onHideFinancialPreview}
|
||||
checked={getHideFinancialPreviewOnPrint(project)} />
|
||||
<label htmlFor="hideFinancialPreview">Cacher le prévisionnel financier lors de l'impression</label>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="field">
|
||||
<label className="label is-size-5">Unité de temps</label>
|
||||
<div className="control">
|
||||
<input className="input" type="text"
|
||||
onChange={onTimeUnitLabelChange}
|
||||
value={timeUnit.label} />
|
||||
</div>
|
||||
<label className="label is-size-6">Acronyme</label>
|
||||
<div className="control">
|
||||
<input className="input" type="text"
|
||||
onChange={onTimeUnitAcronymChange}
|
||||
value={timeUnit.acronym} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<input type="checkbox"
|
||||
id="roundUpEstimations"
|
||||
name="roundUpEstimations"
|
||||
className="switch"
|
||||
onChange={onRoundUpChange}
|
||||
checked={getRoundUpEstimations(project)} />
|
||||
<label htmlFor="roundUpEstimations">Arrondir les estimations de temps à l'entier supérieur</label>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="field">
|
||||
<label className="label is-size-5">Devise</label>
|
||||
<div className="control">
|
||||
<input className="input" type="text"
|
||||
onChange={onCurrencyChange}
|
||||
value={getCurrency(project)} />
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<TaskCategoriesTable project={project} dispatch={dispatch} />
|
||||
<hr />
|
||||
<div>
|
||||
<label className="label is-size-5">Supprimer le projet</label>
|
||||
<div className="field">
|
||||
<input type="checkbox"
|
||||
id="enableDeleteButton"
|
||||
name="enableDeleteButton"
|
||||
className="switch is-warning"
|
||||
onChange={onEnableDeleteButtonChange}
|
||||
checked={deleteButtonEnabled} />
|
||||
<label htmlFor="enableDeleteButton">Supprimer ce projet ?</label>
|
||||
</div>
|
||||
<button className="button is-danger"
|
||||
onClick={onDeleteProjectClick}
|
||||
disabled={!deleteButtonEnabled}>
|
||||
🗑️ Supprimer
|
||||
</button>
|
||||
</div>
|
||||
<hr />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParamsTab;
|
|
@ -0,0 +1,60 @@
|
|||
import React, { FunctionComponent, useEffect } from "react";
|
||||
import style from "./style.module.css";
|
||||
import { newProject, Project } from "../../types/project";
|
||||
import { useProjectReducer, updateProjectLabel } from "../../hooks/useProjectReducer";
|
||||
import EditableText from "../EditableText/EditableText";
|
||||
import Tabs from "../../components/Tabs/Tabs";
|
||||
import EstimationTab from "./EstimationTab";
|
||||
import ParamsTab from "./ParamsTab";
|
||||
import ExportTab from "./ExportTab";
|
||||
import { useParams } from "react-router";
|
||||
import { Page } from "../Page";
|
||||
|
||||
export interface ProjectProps {
|
||||
projectId: string
|
||||
}
|
||||
|
||||
export const ProjectPage: FunctionComponent<ProjectProps> = () => {
|
||||
const { id } = useParams();
|
||||
const [ project, dispatch ] = useProjectReducer(newProject());
|
||||
|
||||
const onProjectLabelChange = (projectLabel: string) => {
|
||||
dispatch(updateProjectLabel(projectLabel));
|
||||
};
|
||||
|
||||
return (
|
||||
<Page title="Projet">
|
||||
<div className="container is-fluid">
|
||||
<div className="columns">
|
||||
<div className="column mt-3 is-10">
|
||||
<EditableText
|
||||
editIconClass="is-size-4"
|
||||
render={(value) => (<h2 className="is-size-3">{value}</h2>)}
|
||||
onChange={onProjectLabelChange}
|
||||
value={project.label ? project.label : "Projet sans nom"}
|
||||
/>
|
||||
<div className={`box mt-3 ${style.tabContainer}`}>
|
||||
<Tabs items={[
|
||||
{
|
||||
label: 'Estimation',
|
||||
icon: 'fa fa-clipboard',
|
||||
render: () => <EstimationTab project={project} dispatch={dispatch} />
|
||||
},
|
||||
{
|
||||
label: 'Options avancées',
|
||||
icon: 'fa fa-sliders-h',
|
||||
render: () => <ParamsTab project={project} dispatch={dispatch} />
|
||||
},
|
||||
{
|
||||
label: 'Exporter',
|
||||
icon: 'fa fa-file-export',
|
||||
render: () => <ExportTab project={project} />
|
||||
}
|
||||
]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,40 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { Project, getTaskCategoriesMeanRepartition } from "../../types/project";
|
||||
|
||||
export interface RepartitionPreviewProps {
|
||||
project: Project
|
||||
}
|
||||
|
||||
const RepartitionPreview: FunctionComponent<RepartitionPreviewProps> = ({ project }) => {
|
||||
const repartition = getTaskCategoriesMeanRepartition(project);
|
||||
return (
|
||||
<div className="table-container">
|
||||
<table className="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;
|
|
@ -0,0 +1,112 @@
|
|||
import React, { FunctionComponent, useState, MouseEvent, ChangeEvent } from "react";
|
||||
import { Project } from "../../types/project";
|
||||
import style from './style.module.css';
|
||||
import { ProjectReducerActions, updateTaskCategoryCost, updateTaskCategoryLabel, removeTaskCategory, addTaskCategory } from "../../hooks/useProjectReducer";
|
||||
import EditableText from "../EditableText/EditableText";
|
||||
import { TaskCategoryID, createTaskCategory } from "../../types/task";
|
||||
import { getCurrency, getTaskCategoryCost } from "../../types/params";
|
||||
|
||||
export interface TaskCategoriesTableProps {
|
||||
project: Project
|
||||
dispatch: (action: ProjectReducerActions) => void
|
||||
}
|
||||
|
||||
const TaskCategoriesTable: FunctionComponent<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: ChangeEvent) => {
|
||||
const costPerTimeUnit = parseFloat((evt.currentTarget as HTMLInputElement).value);
|
||||
setNewTaskCategory(newTaskCategory => ({ ...newTaskCategory, costPerTimeUnit }));
|
||||
};
|
||||
|
||||
const onNewTaskCategoryLabelChange = (evt: ChangeEvent) => {
|
||||
const label = (evt.currentTarget as HTMLInputElement).value;
|
||||
setNewTaskCategory(newTaskCategory => ({ ...newTaskCategory, label }));
|
||||
};
|
||||
|
||||
const onNewTaskCategoryAddClick = (evt: MouseEvent) => {
|
||||
dispatch(addTaskCategory(newTaskCategory));
|
||||
setNewTaskCategory(createTaskCategory());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="table-container">
|
||||
<label className="label is-size-5">Catégories de tâche</label>
|
||||
<table className={`table is-bordered is-striped" ${style.middleTable}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={`${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)}
|
||||
className="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 className={`${style.noBorder}`}></td>
|
||||
<td colSpan={2}>
|
||||
<div className="field has-addons">
|
||||
<p className="control is-expanded">
|
||||
<input className="input" type="text" placeholder="Nouvelle catégorie"
|
||||
value={newTaskCategory.label} onChange={onNewTaskCategoryLabelChange} />
|
||||
</p>
|
||||
<p className="control">
|
||||
<input className="input" type="number"
|
||||
value={newTaskCategory.costPerTimeUnit} onChange={onNewTaskCategoryCostChange} />
|
||||
</p>
|
||||
<p className="control">
|
||||
<a className="button is-static">{getCurrency(project)}</a>
|
||||
</p>
|
||||
<p className="control">
|
||||
<a className="button is-primary" onClick={onNewTaskCategoryAddClick}>
|
||||
Ajouter
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskCategoriesTable;
|
|
@ -0,0 +1,198 @@
|
|||
import React, { FunctionComponent, useState, useEffect, ChangeEvent, MouseEvent } from "react";
|
||||
import style from "./style.module.css";
|
||||
import { Project } from "../../types/project";
|
||||
import { newTask, Task, TaskID, EstimationConfidence } from "../../types/task";
|
||||
import EditableText from "../EditableText/EditableText";
|
||||
import { usePrintMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import ProjectTimeUnit from "../ProjectTimeUnit";
|
||||
|
||||
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: FunctionComponent<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: ChangeEvent) => {
|
||||
const value = (evt.currentTarget as HTMLInputElement).value;
|
||||
setTask({...task, label: value});
|
||||
};
|
||||
|
||||
const onNewTaskCategoryChange = (evt: ChangeEvent) => {
|
||||
const value = (evt.currentTarget as HTMLInputElement).value;
|
||||
setTask({...task, category: value});
|
||||
};
|
||||
|
||||
const onTaskLabelChange = (taskId: TaskID, value: string) => {
|
||||
onTaskLabelUpdate(taskId, value);
|
||||
};
|
||||
|
||||
const onAddTaskClick = (evt: MouseEvent) => {
|
||||
onTaskAdd(task);
|
||||
setTask(newTask("", defaultTaskCategory));
|
||||
};
|
||||
|
||||
const onTaskRemoveClick = (taskId: TaskID, evt: MouseEvent) => {
|
||||
onTaskRemove(taskId);
|
||||
};
|
||||
|
||||
const withEstimationChange = (confidence: EstimationConfidence, taskID: TaskID, evt: ChangeEvent) => {
|
||||
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 className="table-container">
|
||||
<table className={`table is-bordered is-striped is-hoverable is-fullwidth ${style.middleTable}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={`${style.noBorder} noPrint`} rowSpan={2}></th>
|
||||
<th className={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 className={`is-narrow noPrint`}>
|
||||
<button
|
||||
onClick={onTaskRemoveClick.bind(null, t.id)}
|
||||
className="button is-danger is-small is-outlined">
|
||||
🗑️
|
||||
</button>
|
||||
</td>
|
||||
<td className={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 className="input" type="number" value={t.estimations.optimistic}
|
||||
min={0}
|
||||
onChange={onOptimisticChange.bind(null, t.id)} />
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{
|
||||
isPrint ?
|
||||
<span>{t.estimations.likely}</span> :
|
||||
<input className="input" type="number" value={t.estimations.likely}
|
||||
min={0}
|
||||
onChange={onLikelyChange.bind(null, t.id)} />
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{
|
||||
isPrint ?
|
||||
<span>{t.estimations.pessimistic}</span> :
|
||||
<input className="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 className={`${style.noBorder} noPrint`}></td>
|
||||
<td className={style.noTasks} colSpan={5}>Aucune tâche pour l'instant.</td>
|
||||
</tr> :
|
||||
null
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td className={`${style.noBorder} noPrint`}></td>
|
||||
<td colSpan={2} className={isPrint ? style.noBorder : ''}>
|
||||
<div className="field has-addons noPrint">
|
||||
<p className="control is-expanded">
|
||||
<input className="input" type="text" placeholder="Nouvelle tâche"
|
||||
value={task.label} onChange={onNewTaskLabelChange} />
|
||||
</p>
|
||||
<p className="control">
|
||||
<span className="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 className="control">
|
||||
<a className="button is-primary" onClick={onAddTaskClick}>
|
||||
Ajouter
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<th colSpan={3}>Total</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={isPrint ? 2 : 3} className={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;
|
|
@ -0,0 +1,48 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { Project } from "../../types/project";
|
||||
import { useProjectEstimations } from "../../hooks/useProjectEstimations";
|
||||
import EstimationRange from "../EstimationRange";
|
||||
|
||||
export interface TimePreviewProps {
|
||||
project: Project
|
||||
}
|
||||
|
||||
export const TimePreview: FunctionComponent<TimePreviewProps> = ({ project }) => {
|
||||
const estimations = useProjectEstimations(project);
|
||||
return (
|
||||
<div className="table-container">
|
||||
<table className="table is-bordered is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={2}>Prévisionnel temps</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="is-narrow">Confiance</th>
|
||||
<th>Estimation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="is-narrow">>= 99.7%</td>
|
||||
<td><EstimationRange project={project} estimation={estimations.p99} /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="is-narrow">>= 90%</td>
|
||||
<td><EstimationRange project={project} estimation={estimations.p90} /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="is-narrow">>= 68%</td>
|
||||
<td><EstimationRange project={project} estimation={estimations.p68} /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot className="noPrint">
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<a className="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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
declare namespace StyleModuleCssNamespace {
|
||||
export interface IStyleModuleCss {
|
||||
estimation: string;
|
||||
mainColumn: string;
|
||||
middleTable: string;
|
||||
noBorder: string;
|
||||
noTasks: string;
|
||||
tabContainer: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare const StyleModuleCssModule: StyleModuleCssNamespace.IStyleModuleCss & {
|
||||
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
|
||||
locals: StyleModuleCssNamespace.IStyleModuleCss;
|
||||
};
|
||||
|
||||
export = StyleModuleCssModule;
|
|
@ -0,0 +1,16 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { Project } from "../types/project";
|
||||
import { getTimeUnit } from "../types/params";
|
||||
|
||||
export interface ProjectTimeUnitProps {
|
||||
project: Project
|
||||
}
|
||||
|
||||
const ProjectTimeUnit: FunctionComponent<ProjectTimeUnitProps> = ({ project }) => {
|
||||
const timeUnit = getTimeUnit(project);
|
||||
return (
|
||||
<abbr title={timeUnit.label}>{timeUnit.acronym}</abbr>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectTimeUnit;
|
|
@ -0,0 +1,49 @@
|
|||
import React, { FunctionComponent, useState, ReactNode } from "react";
|
||||
import style from "./style.module.css";
|
||||
|
||||
export interface TabItem {
|
||||
label: string
|
||||
icon?: string
|
||||
render: () => ReactNode
|
||||
}
|
||||
|
||||
export interface TabsProps {
|
||||
class?: string
|
||||
items: TabItem[]
|
||||
}
|
||||
|
||||
const Tabs: FunctionComponent<TabsProps> = ({ items, ...props }) => {
|
||||
const [ selectedTabIndex, setSelectedTabIndex ] = useState(0);
|
||||
|
||||
const onTabClick = (tabIndex: number) => {
|
||||
setSelectedTabIndex(tabIndex);
|
||||
};
|
||||
|
||||
const selectedTab = items[selectedTabIndex];
|
||||
|
||||
return (
|
||||
<div className={`${style.tabs} ${props.class}`}>
|
||||
<div className="tabs is-boxed">
|
||||
<ul className={`noPrint`}>
|
||||
{
|
||||
items.map((tabItem, tabIndex) => (
|
||||
<li key={`tab-${tabIndex}`}
|
||||
onClick={onTabClick.bind(null, tabIndex)}
|
||||
className={`${selectedTabIndex === tabIndex ? 'is-active' : ''}`}>
|
||||
<a>
|
||||
<span className={`icon is-small`}><i className={`${tabItem.icon}`}></i></span>
|
||||
<span>{tabItem.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div className={style.tabContent}>
|
||||
{ selectedTab.render() }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tabs;
|
|
@ -0,0 +1,8 @@
|
|||
.tabs {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
padding-top: 1em;
|
||||
max-width: 100%;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
declare namespace StyleModuleCssNamespace {
|
||||
export interface IStyleModuleCss {
|
||||
tabContent: string;
|
||||
tabs: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare const StyleModuleCssModule: StyleModuleCssNamespace.IStyleModuleCss & {
|
||||
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
|
||||
locals: StyleModuleCssNamespace.IStyleModuleCss;
|
||||
};
|
||||
|
||||
export = StyleModuleCssModule;
|
|
@ -0,0 +1,41 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const media = window.matchMedia(query);
|
||||
const [ matches, setMatches ] = useState(media.matches);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (evt: MediaQueryListEvent) => {
|
||||
setMatches(evt.matches);
|
||||
};
|
||||
media.addListener(listener);
|
||||
return () => {media.removeListener(listener)}
|
||||
}, []);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function usePrintMediaQuery(): boolean {
|
||||
const isMediaQueryPrint = useMediaQuery("print");
|
||||
const [ isPrint, setIsPrint ] = useState(false);
|
||||
|
||||
// Firefox/IE compatibility layer
|
||||
useEffect(() => {
|
||||
const beforePrint = () => {
|
||||
setIsPrint(true);
|
||||
};
|
||||
const afterPrint = () => {
|
||||
setIsPrint(false);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeprint', beforePrint);
|
||||
window.addEventListener('afterprint', afterPrint);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeprint', beforePrint);
|
||||
window.removeEventListener('afterprint', afterPrint);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isMediaQueryPrint || isPrint;
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { Project, getProjectWeightedMean, getProjectStandardDeviation } from "../types/project";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export interface Estimation {
|
||||
e: number
|
||||
sd: number
|
||||
}
|
||||
|
||||
export interface ProjetEstimations {
|
||||
p99: Estimation
|
||||
p90: Estimation
|
||||
p68: Estimation
|
||||
}
|
||||
|
||||
export function useProjectEstimations(p :Project): ProjetEstimations {
|
||||
const [ estimations, setEstimations ] = useState({
|
||||
p99: { e: 0, sd: 0 },
|
||||
p90: { e: 0, sd: 0 },
|
||||
p68: { e: 0, sd: 0 },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const projectWeightedMean = getProjectWeightedMean(p)
|
||||
const projectStandardDeviation = getProjectStandardDeviation(p);
|
||||
|
||||
setEstimations({
|
||||
p99: { e: projectWeightedMean, sd: (projectStandardDeviation * 3) },
|
||||
p90: { e: projectWeightedMean, sd: (projectStandardDeviation * 1.645) },
|
||||
p68: { e: projectWeightedMean, sd: (projectStandardDeviation) },
|
||||
})
|
||||
}, [p.tasks]);
|
||||
|
||||
return estimations;
|
||||
}
|
|
@ -0,0 +1,302 @@
|
|||
import { Project } from "../types/project";
|
||||
import { Task, TaskID, EstimationConfidence, TaskCategoryID, TaskCategory } from "../types/task";
|
||||
import { useReducer } from "react";
|
||||
|
||||
export interface Action {
|
||||
type: string
|
||||
}
|
||||
|
||||
export type ProjectReducerActions =
|
||||
AddTask |
|
||||
RemoveTask |
|
||||
UpdateTaskEstimation |
|
||||
UpdateProjectLabel |
|
||||
UpdateTaskLabel |
|
||||
UpdateParam |
|
||||
UpdateTaskCategoryLabel |
|
||||
UpdateTaskCategoryCost |
|
||||
AddTaskCategory |
|
||||
RemoveTaskCategory
|
||||
|
||||
export function useProjectReducer(project: Project) {
|
||||
return useReducer(projectReducer, project);
|
||||
}
|
||||
|
||||
export function projectReducer(project: Project, action: ProjectReducerActions): Project {
|
||||
console.log(action);
|
||||
switch(action.type) {
|
||||
case ADD_TASK:
|
||||
return handleAddTask(project, action as AddTask);
|
||||
|
||||
case REMOVE_TASK:
|
||||
return handleRemoveTask(project, action as RemoveTask);
|
||||
|
||||
case UPDATE_TASK_ESTIMATION:
|
||||
return handleUpdateTaskEstimation(project, action as UpdateTaskEstimation);
|
||||
|
||||
case UPDATE_PROJECT_LABEL:
|
||||
return handleUpdateProjectLabel(project, action as UpdateProjectLabel);
|
||||
|
||||
case UPDATE_TASK_LABEL:
|
||||
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;
|
||||
}
|
||||
|
||||
export interface AddTask extends Action {
|
||||
task: Task
|
||||
}
|
||||
|
||||
export const ADD_TASK = "ADD_TASK";
|
||||
|
||||
export function addTask(task: Task): AddTask {
|
||||
return { type: ADD_TASK, task };
|
||||
}
|
||||
|
||||
export function handleAddTask(project: Project, action: AddTask): Project {
|
||||
const task = { ...action.task };
|
||||
return {
|
||||
...project,
|
||||
tasks: {
|
||||
...project.tasks,
|
||||
[task.id]: task,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface RemoveTask extends Action {
|
||||
id: TaskID
|
||||
}
|
||||
|
||||
export const REMOVE_TASK = "REMOVE_TASK";
|
||||
|
||||
export function removeTask(id: TaskID): RemoveTask {
|
||||
return { type: REMOVE_TASK, id };
|
||||
}
|
||||
|
||||
export function handleRemoveTask(project: Project, action: RemoveTask): Project {
|
||||
const tasks = { ...project.tasks };
|
||||
delete tasks[action.id];
|
||||
return {
|
||||
...project,
|
||||
tasks
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateTaskEstimation extends Action {
|
||||
id: TaskID
|
||||
confidence: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export const UPDATE_TASK_ESTIMATION = "UPDATE_TASK_ESTIMATION";
|
||||
|
||||
export function updateTaskEstimation(id: TaskID, confidence: EstimationConfidence, value: number): UpdateTaskEstimation {
|
||||
return { type: UPDATE_TASK_ESTIMATION, id, confidence, value };
|
||||
}
|
||||
|
||||
export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskEstimation): Project {
|
||||
const estimations = {
|
||||
...project.tasks[action.id].estimations,
|
||||
[action.confidence]: action.value
|
||||
};
|
||||
|
||||
if (estimations.likely < estimations.optimistic) {
|
||||
estimations.likely = estimations.optimistic;
|
||||
}
|
||||
|
||||
if (estimations.pessimistic < estimations.likely) {
|
||||
estimations.pessimistic = estimations.likely;
|
||||
}
|
||||
|
||||
return {
|
||||
...project,
|
||||
tasks: {
|
||||
...project.tasks,
|
||||
[action.id]: {
|
||||
...project.tasks[action.id],
|
||||
estimations: estimations,
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export interface UpdateProjectLabel extends Action {
|
||||
label: string
|
||||
}
|
||||
|
||||
export const UPDATE_PROJECT_LABEL = "UPDATE_PROJECT_LABEL";
|
||||
|
||||
export function updateProjectLabel(label: string): UpdateProjectLabel {
|
||||
return { type: UPDATE_PROJECT_LABEL, label };
|
||||
}
|
||||
|
||||
export function handleUpdateProjectLabel(project: Project, action: UpdateProjectLabel): Project {
|
||||
return {
|
||||
...project,
|
||||
label: action.label
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateTaskLabel extends Action {
|
||||
id: TaskID
|
||||
label: string
|
||||
}
|
||||
|
||||
export const UPDATE_TASK_LABEL = "UPDATE_TASK_LABEL";
|
||||
|
||||
export function updateTaskLabel(id: TaskID, label: string): UpdateTaskLabel {
|
||||
return { type: UPDATE_TASK_LABEL, id, label };
|
||||
}
|
||||
|
||||
export function handleUpdateTaskLabel(project: Project, action: UpdateTaskLabel): Project {
|
||||
return {
|
||||
...project,
|
||||
tasks: {
|
||||
...project.tasks,
|
||||
[action.id]: {
|
||||
...project.tasks[action.id],
|
||||
label: action.label,
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
|
@ -1,14 +1,18 @@
|
|||
import './sass/_all.scss';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { App } from './components/App';
|
||||
import { client } from './gql/client';
|
||||
|
||||
import "./style/index.css";
|
||||
import "bulma/css/bulma.css";
|
||||
import "bulma-switch/dist/css/bulma-switch.min.css";
|
||||
|
||||
import '@fortawesome/fontawesome-free/js/fontawesome'
|
||||
import '@fortawesome/fontawesome-free/js/solid'
|
||||
import '@fortawesome/fontawesome-free/js/regular'
|
||||
import '@fortawesome/fontawesome-free/js/brands'
|
||||
import './resources/favicon.png';
|
||||
|
||||
import { ApolloProvider } from '@apollo/client';
|
||||
|
||||
ReactDOM.render(
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
@import 'bulma/bulma.sass';
|
||||
@import '_base.scss';
|
||||
@import '_loader.scss';
|
|
@ -1,44 +0,0 @@
|
|||
.loader-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lds-ripple {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
transform: scale(2);
|
||||
}
|
||||
|
||||
.lds-ripple div {
|
||||
position: absolute;
|
||||
border: 4px solid $grey;
|
||||
opacity: 1;
|
||||
border-radius: 50%;
|
||||
animation: lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||
}
|
||||
|
||||
.lds-ripple div:nth-child(2) {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
@keyframes lds-ripple {
|
||||
0% {
|
||||
top: 36px;
|
||||
left: 36px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
|
@ -1,7 +1,18 @@
|
|||
#app {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
@media print
|
||||
{
|
||||
.noPrint, .noPrint * {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background-color: #ffffff;
|
||||
// Generated with https://www.svgbackgrounds.com/
|
||||
/* Generated with https://www.svgbackgrounds.com/ */
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='351' height='292.5' viewBox='0 0 1080 900'%3E%3Cg fill-opacity='0.04'%3E%3Cpolygon fill='%23444' points='90 150 0 300 180 300'/%3E%3Cpolygon points='90 150 180 0 0 0'/%3E%3Cpolygon fill='%23AAA' points='270 150 360 0 180 0'/%3E%3Cpolygon fill='%23DDD' points='450 150 360 300 540 300'/%3E%3Cpolygon fill='%23999' points='450 150 540 0 360 0'/%3E%3Cpolygon points='630 150 540 300 720 300'/%3E%3Cpolygon fill='%23DDD' points='630 150 720 0 540 0'/%3E%3Cpolygon fill='%23444' points='810 150 720 300 900 300'/%3E%3Cpolygon fill='%23FFF' points='810 150 900 0 720 0'/%3E%3Cpolygon fill='%23DDD' points='990 150 900 300 1080 300'/%3E%3Cpolygon fill='%23444' points='990 150 1080 0 900 0'/%3E%3Cpolygon fill='%23DDD' points='90 450 0 600 180 600'/%3E%3Cpolygon points='90 450 180 300 0 300'/%3E%3Cpolygon fill='%23666' points='270 450 180 600 360 600'/%3E%3Cpolygon fill='%23AAA' points='270 450 360 300 180 300'/%3E%3Cpolygon fill='%23DDD' points='450 450 360 600 540 600'/%3E%3Cpolygon fill='%23999' points='450 450 540 300 360 300'/%3E%3Cpolygon fill='%23999' points='630 450 540 600 720 600'/%3E%3Cpolygon fill='%23FFF' points='630 450 720 300 540 300'/%3E%3Cpolygon points='810 450 720 600 900 600'/%3E%3Cpolygon fill='%23DDD' points='810 450 900 300 720 300'/%3E%3Cpolygon fill='%23AAA' points='990 450 900 600 1080 600'/%3E%3Cpolygon fill='%23444' points='990 450 1080 300 900 300'/%3E%3Cpolygon fill='%23222' points='90 750 0 900 180 900'/%3E%3Cpolygon points='270 750 180 900 360 900'/%3E%3Cpolygon fill='%23DDD' points='270 750 360 600 180 600'/%3E%3Cpolygon points='450 750 540 600 360 600'/%3E%3Cpolygon points='630 750 540 900 720 900'/%3E%3Cpolygon fill='%23444' points='630 750 720 600 540 600'/%3E%3Cpolygon fill='%23AAA' points='810 750 720 900 900 900'/%3E%3Cpolygon fill='%23666' points='810 750 900 600 720 600'/%3E%3Cpolygon fill='%23999' points='990 750 900 900 1080 900'/%3E%3Cpolygon fill='%23999' points='180 0 90 150 270 150'/%3E%3Cpolygon fill='%23444' points='360 0 270 150 450 150'/%3E%3Cpolygon fill='%23FFF' points='540 0 450 150 630 150'/%3E%3Cpolygon points='900 0 810 150 990 150'/%3E%3Cpolygon fill='%23222' points='0 300 -90 450 90 450'/%3E%3Cpolygon fill='%23FFF' points='0 300 90 150 -90 150'/%3E%3Cpolygon fill='%23FFF' points='180 300 90 450 270 450'/%3E%3Cpolygon fill='%23666' points='180 300 270 150 90 150'/%3E%3Cpolygon fill='%23222' points='360 300 270 450 450 450'/%3E%3Cpolygon fill='%23FFF' points='360 300 450 150 270 150'/%3E%3Cpolygon fill='%23444' points='540 300 450 450 630 450'/%3E%3Cpolygon fill='%23222' points='540 300 630 150 450 150'/%3E%3Cpolygon fill='%23AAA' points='720 300 630 450 810 450'/%3E%3Cpolygon fill='%23666' points='720 300 810 150 630 150'/%3E%3Cpolygon fill='%23FFF' points='900 300 810 450 990 450'/%3E%3Cpolygon fill='%23999' points='900 300 990 150 810 150'/%3E%3Cpolygon points='0 600 -90 750 90 750'/%3E%3Cpolygon fill='%23666' points='0 600 90 450 -90 450'/%3E%3Cpolygon fill='%23AAA' points='180 600 90 750 270 750'/%3E%3Cpolygon fill='%23444' points='180 600 270 450 90 450'/%3E%3Cpolygon fill='%23444' points='360 600 270 750 450 750'/%3E%3Cpolygon fill='%23999' points='360 600 450 450 270 450'/%3E%3Cpolygon fill='%23666' points='540 600 630 450 450 450'/%3E%3Cpolygon fill='%23222' points='720 600 630 750 810 750'/%3E%3Cpolygon fill='%23FFF' points='900 600 810 750 990 750'/%3E%3Cpolygon fill='%23222' points='900 600 990 450 810 450'/%3E%3Cpolygon fill='%23DDD' points='0 900 90 750 -90 750'/%3E%3Cpolygon fill='%23444' points='180 900 270 750 90 750'/%3E%3Cpolygon fill='%23FFF' points='360 900 450 750 270 750'/%3E%3Cpolygon fill='%23AAA' points='540 900 630 750 450 750'/%3E%3Cpolygon fill='%23FFF' points='720 900 810 750 630 750'/%3E%3Cpolygon fill='%23222' points='900 900 990 750 810 750'/%3E%3Cpolygon fill='%23222' points='1080 300 990 450 1170 450'/%3E%3Cpolygon fill='%23FFF' points='1080 300 1170 150 990 150'/%3E%3Cpolygon points='1080 600 990 750 1170 750'/%3E%3Cpolygon fill='%23666' points='1080 600 1170 450 990 450'/%3E%3Cpolygon fill='%23DDD' points='1080 900 1170 750 990 750'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
|
@ -9,10 +20,6 @@ html, body {
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.has-margin-top-normal {
|
||||
margin-top: $size-normal;
|
||||
}
|
||||
|
||||
.has-padding-small {
|
||||
padding: 1rem;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
declare namespace IndexCssModule {
|
||||
export interface IIndexCss {
|
||||
app: string;
|
||||
noPrint: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare const IndexCssModule: IndexCssModule.IIndexCss & {
|
||||
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
|
||||
locals: IndexCssModule.IIndexCss;
|
||||
};
|
||||
|
||||
export = IndexCssModule;
|
|
@ -1,35 +0,0 @@
|
|||
import { Workgroup } from "./workgroup";
|
||||
|
||||
export enum DecisionSupportFileStatus {
|
||||
Draft = "draft",
|
||||
Ready = "ready",
|
||||
Voted = "voted",
|
||||
Closed = "closed",
|
||||
}
|
||||
|
||||
export interface DecisionSupportFileSection {
|
||||
name: string
|
||||
}
|
||||
|
||||
// aka Dossier d'aide à la décision
|
||||
export interface DecisionSupportFile {
|
||||
id: string
|
||||
title: string
|
||||
sections: {[name: string]: any}
|
||||
status: DecisionSupportFileStatus
|
||||
workgroup?: Workgroup,
|
||||
createdAt: Date
|
||||
votedAt?: Date
|
||||
closedAt?: Date
|
||||
}
|
||||
|
||||
export function newDecisionSupportFile(): DecisionSupportFile {
|
||||
return {
|
||||
id: '',
|
||||
title: '',
|
||||
sections: {},
|
||||
status: DecisionSupportFileStatus.Draft,
|
||||
workgroup: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
import { TaskCategory, TaskCategoryID } from "./task";
|
||||
import { Project } from "./project";
|
||||
|
||||
export interface TaskCategoriesIndex {
|
||||
[id: string]: TaskCategory
|
||||
}
|
||||
|
||||
export interface TimeUnit {
|
||||
label: string
|
||||
acronym: string
|
||||
}
|
||||
|
||||
export interface Params {
|
||||
taskCategories: TaskCategoriesIndex
|
||||
timeUnit: TimeUnit
|
||||
currency: string
|
||||
roundUpEstimations: boolean
|
||||
hideFinancialPreviewOnPrint: boolean
|
||||
}
|
||||
|
||||
export const defaults = {
|
||||
taskCategories: {
|
||||
"RQ15CD3iX1Ey2f9kat7tfLGZmUx9GGc15nS6A7fYtZv76SnS4": {
|
||||
id: "RQ15CD3iX1Ey2f9kat7tfLGZmUx9GGc15nS6A7fYtZv76SnS4",
|
||||
label: "Développement",
|
||||
costPerTimeUnit: 500,
|
||||
},
|
||||
"QRdGS5Pr5si9SSjU84WAq19cjxQ3rUL71jKh8oHSMZSY4bBH9": {
|
||||
id: "QRdGS5Pr5si9SSjU84WAq19cjxQ3rUL71jKh8oHSMZSY4bBH9",
|
||||
label: "Conduite de projet",
|
||||
costPerTimeUnit: 500,
|
||||
},
|
||||
"RPcqFMLdQrgBSomv7Sao7EQSb7on6rtjfDQK5JZNhNSg9DwEo": {
|
||||
id: "RPcqFMLdQrgBSomv7Sao7EQSb7on6rtjfDQK5JZNhNSg9DwEo",
|
||||
label: "Recette",
|
||||
costPerTimeUnit: 500,
|
||||
},
|
||||
},
|
||||
timeUnit: {
|
||||
label: "jour/homme",
|
||||
acronym: "j/h",
|
||||
},
|
||||
roundUpEstimations: true,
|
||||
currency: "€ H.T.",
|
||||
costPerTimeUnit: 500,
|
||||
hideFinancialPreviewOnPrint: false,
|
||||
}
|
||||
|
||||
export function getTimeUnit(project: Project): TimeUnit {
|
||||
return project.params.timeUnit ? project.params.timeUnit : defaults.timeUnit;
|
||||
}
|
||||
|
||||
export function getRoundUpEstimations(project: Project): boolean {
|
||||
return project.params.hasOwnProperty("roundUpEstimations") ? project.params.roundUpEstimations : defaults.roundUpEstimations;
|
||||
}
|
||||
|
||||
export function getCurrency(project: Project): string {
|
||||
return project.params.currency ? project.params.currency : defaults.currency;
|
||||
}
|
||||
|
||||
export function getTaskCategories(project: Project): TaskCategoriesIndex {
|
||||
return project.params.taskCategories ? project.params.taskCategories : defaults.taskCategories;
|
||||
}
|
||||
|
||||
export function getTaskCategoryCost(taskCategory: TaskCategory): number {
|
||||
return taskCategory.hasOwnProperty("costPerTimeUnit") ? taskCategory.costPerTimeUnit : defaults.costPerTimeUnit;
|
||||
}
|
||||
|
||||
export function getHideFinancialPreviewOnPrint(project: Project): boolean {
|
||||
return project.params.hasOwnProperty("hideFinancialPreviewOnPrint") ? project.params.hideFinancialPreviewOnPrint : defaults.hideFinancialPreviewOnPrint;
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
import { Task, TaskCategory, TaskID, getTaskWeightedMean, TaskCategoryID, getTaskStandardDeviation } from './task';
|
||||
import { Params, defaults, getTaskCategoryCost } from "./params";
|
||||
import { Estimation } from '../hooks/useProjectEstimations';
|
||||
|
||||
export type ProjectID = string;
|
||||
|
||||
export interface Project {
|
||||
id: ProjectID
|
||||
label: string
|
||||
description: string
|
||||
tasks: Tasks
|
||||
params: Params
|
||||
}
|
||||
|
||||
export interface Tasks {
|
||||
[id: string]: Task
|
||||
}
|
||||
|
||||
export function newProject(): Project {
|
||||
return {
|
||||
id: "",
|
||||
label: "",
|
||||
description: "",
|
||||
tasks: {},
|
||||
params: {
|
||||
...defaults
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getProjectWeightedMean(p : Project): number {
|
||||
return Object.values(p.tasks).reduce((sum: number, t: Task) => {
|
||||
sum += getTaskWeightedMean(t);
|
||||
return sum;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function getTaskCategoryWeightedMean(taskCategoryId: TaskCategoryID, p : Project): number {
|
||||
return Object.values(p.tasks).filter(t => t.category === taskCategoryId).reduce((sum: number, t: Task) => {
|
||||
sum += getTaskWeightedMean(t);
|
||||
return sum;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function getProjectStandardDeviation(p : Project): number {
|
||||
return Math.sqrt(Object.values(p.tasks).reduce((sum: number, t: Task) => {
|
||||
sum += Math.pow(getTaskStandardDeviation(t), 2);
|
||||
return sum;
|
||||
}, 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.round((estimation.e + estimation.sd) * repartition[tc.id]);
|
||||
max.details[tc.id] = {
|
||||
time: maxTime,
|
||||
cost: Math.ceil(maxTime) * cost,
|
||||
};
|
||||
max.totalTime += max.details[tc.id].time;
|
||||
max.totalCost += max.details[tc.id].cost;
|
||||
|
||||
const minTime = Math.round((estimation.e - estimation.sd) * repartition[tc.id]);
|
||||
min.details[tc.id] = {
|
||||
time: minTime,
|
||||
cost: Math.ceil(minTime) * cost,
|
||||
};
|
||||
min.totalTime += min.details[tc.id].time;
|
||||
min.totalCost += min.details[tc.id].cost;
|
||||
});
|
||||
|
||||
return { max, min };
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import { defaults } from "./params";
|
||||
|
||||
export type TaskID = string
|
||||
|
||||
export enum EstimationConfidence {
|
||||
Optimistic = "optimistic",
|
||||
Likely = "likely",
|
||||
Pessimistic = "pessimistic"
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: TaskID
|
||||
label: string
|
||||
category: TaskCategoryID
|
||||
estimations: { [confidence in EstimationConfidence]: number }
|
||||
}
|
||||
|
||||
export type TaskCategoryID = string
|
||||
|
||||
export interface TaskCategory {
|
||||
id: TaskCategoryID
|
||||
label: string
|
||||
costPerTimeUnit: number
|
||||
}
|
||||
|
||||
export function newTask(label: string, category: TaskCategoryID): Task {
|
||||
return {
|
||||
id: '',
|
||||
label,
|
||||
category,
|
||||
estimations: {
|
||||
[EstimationConfidence.Optimistic]: 0,
|
||||
[EstimationConfidence.Likely]: 0,
|
||||
[EstimationConfidence.Pessimistic]: 0,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createTaskCategory(): TaskCategory {
|
||||
return {
|
||||
id: '',
|
||||
costPerTimeUnit: defaults.costPerTimeUnit,
|
||||
label: ""
|
||||
};
|
||||
}
|
||||
|
||||
export function getTaskWeightedMean(t: Task): number {
|
||||
return (t.estimations.optimistic + (4*t.estimations.likely) + t.estimations.pessimistic) / 6;
|
||||
}
|
||||
|
||||
export function getTaskStandardDeviation(t: Task): number {
|
||||
return (t.estimations.pessimistic - t.estimations.optimistic) / 6;
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
import { User } from "./user";
|
||||
export interface Workgroup {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: Date
|
||||
closedAt: Date
|
||||
members: User[]
|
||||
}
|
||||
|
||||
export function inWorkgroup(u: User, wg: Workgroup): boolean {
|
||||
for (let m, i = 0; (m = wg.members[i]); i++) {
|
||||
if(m.id === u.id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
|
@ -28,40 +28,44 @@ module.exports = {
|
|||
writeToDisk: true,
|
||||
},
|
||||
module: {
|
||||
rules: [{
|
||||
test: /\.s(a|c)ss$/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {}
|
||||
},
|
||||
{
|
||||
loader: "resolve-url-loader",
|
||||
options: {}
|
||||
},
|
||||
{
|
||||
loader: "sass-loader",
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
exclude: /node_modules/,
|
||||
loaders: ['ts-loader']
|
||||
},
|
||||
{
|
||||
test: /\.module\.css$/,
|
||||
use: [
|
||||
{
|
||||
loader: '@teamsupercell/typings-for-css-modules-loader',
|
||||
},
|
||||
{
|
||||
loader: MiniCssExtractPlugin.loader,
|
||||
},
|
||||
{ loader: "css-loader", options: { modules: true } }
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /^((?!\.module).)*css$/,
|
||||
use: [
|
||||
{
|
||||
loader: MiniCssExtractPlugin.loader,
|
||||
},
|
||||
{ loader: "css-loader" },
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /\.(woff(2)?|ttf|eot|svg|png)(\?v=\d+\.\d+\.\d+)?$/,
|
||||
use: [{
|
||||
loader: 'file-loader',
|
||||
options: {
|
||||
sourceMap: true,
|
||||
sourceMapContents: false
|
||||
name: '[name].[contenthash].[ext]',
|
||||
outputPath: '/resources/'
|
||||
}
|
||||
}
|
||||
]
|
||||
},{
|
||||
test: /\.(woff(2)?|ttf|eot|svg|png)(\?v=\d+\.\d+\.\d+)?$/,
|
||||
use: [{
|
||||
loader: 'file-loader',
|
||||
options: {
|
||||
name: '[name].[contenthash].[ext]',
|
||||
outputPath: '/resources/'
|
||||
}
|
||||
}]
|
||||
},{
|
||||
test: /\.(t|j)sx?$/,
|
||||
exclude: /node_modules/,
|
||||
loaders: ['ts-loader']
|
||||
}]
|
||||
}]
|
||||
}
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new CleanWebpackPlugin(),
|
||||
|
@ -73,6 +77,8 @@ module.exports = {
|
|||
template: './src/index.html',
|
||||
inject: false,
|
||||
favicon: "./src/resources/favicon.png",
|
||||
title: 'Guesstimate',
|
||||
scriptLoading: 'defer',
|
||||
}),
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
|
|
Loading…
Reference in New Issue