diff --git a/client/package-lock.json b/client/package-lock.json index 9bacf32..352d88d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index 9aa6872..3adb70c 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/components/App.tsx b/client/src/components/App.tsx index 2bbe2ed..ef6cd18 100644 --- a/client/src/components/App.tsx +++ b/client/src/components/App.tsx @@ -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 = () => { + } /> diff --git a/client/src/components/DashboardPage/Dashboard.tsx b/client/src/components/DashboardPage/Dashboard.tsx index 8a87750..db6b59e 100644 --- a/client/src/components/DashboardPage/Dashboard.tsx +++ b/client/src/components/DashboardPage/Dashboard.tsx @@ -1,18 +1,18 @@ import React from 'react'; -import { EstimationPanel } from './EstimationPanel'; +import { ProjectPanel } from './ProjectPanel'; +import { ProjectModelPanel } from './ProjectModelPanel'; export function Dashboard() { return ( -
-
-
- -
-
-
-
- -
+
+
+ +
+
+ +
+
+
); diff --git a/client/src/components/DashboardPage/DashboardPage.tsx b/client/src/components/DashboardPage/DashboardPage.tsx index a7a3509..75b014b 100644 --- a/client/src/components/DashboardPage/DashboardPage.tsx +++ b/client/src/components/DashboardPage/DashboardPage.tsx @@ -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 (
- +
+

Bonjour {user.name !== '' ? user.name : ' utilisateur mystère'} ! Sur quel projet souhaitez vous travailler aujourd'hui ?

+ +
); diff --git a/client/src/components/DashboardPage/ProjectModelPanel.tsx b/client/src/components/DashboardPage/ProjectModelPanel.tsx new file mode 100644 index 0000000..8bd9498 --- /dev/null +++ b/client/src/components/DashboardPage/ProjectModelPanel.tsx @@ -0,0 +1,20 @@ +import React, { FunctionComponent } from "react"; +import { ItemPanel } from "./ItemPanel"; + +export interface ProjectModelPanelProps { + +} + +export const ProjectModelPanel: FunctionComponent = () => { + return ( + { return item.id }} + itemLabel={(item) => { return item.id }} + itemUrl={(item) => { return `/models/${item.id}` }} + /> + ); +}; \ No newline at end of file diff --git a/client/src/components/DashboardPage/EstimationPanel.tsx b/client/src/components/DashboardPage/ProjectPanel.tsx similarity index 52% rename from client/src/components/DashboardPage/EstimationPanel.tsx rename to client/src/components/DashboardPage/ProjectPanel.tsx index 7090716..df5f69e 100644 --- a/client/src/components/DashboardPage/EstimationPanel.tsx +++ b/client/src/components/DashboardPage/ProjectPanel.tsx @@ -1,20 +1,20 @@ import React, { FunctionComponent } from "react"; import { ItemPanel } from "./ItemPanel"; -export interface EstimationPanelProps { +export interface ProjectPanelProps { } -export const EstimationPanel: FunctionComponent = () => { +export const ProjectPanel: FunctionComponent = () => { return ( { return item.id }} itemLabel={(item) => { return item.id }} - itemUrl={(item) => { return `/estimations/${item.id}` }} + itemUrl={(item) => { return `/projects/${item.id}` }} /> ); }; \ No newline at end of file diff --git a/client/src/components/EditableText/EditableText.tsx b/client/src/components/EditableText/EditableText.tsx new file mode 100644 index 0000000..71fdd60 --- /dev/null +++ b/client/src/components/EditableText/EditableText.tsx @@ -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 = ({ 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 ( +
+ { + editMode ? +
+
+ +
+
+ ✔️ +
+
: + + { render ? render(internalValue) : {internalValue} } + 🖋️ + + } +
+ ); +}; + +export default EditableText; diff --git a/client/src/components/EditableText/style.module.css b/client/src/components/EditableText/style.module.css new file mode 100644 index 0000000..11cb78c --- /dev/null +++ b/client/src/components/EditableText/style.module.css @@ -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; +} \ No newline at end of file diff --git a/client/src/components/EditableText/style.module.css.d.ts b/client/src/components/EditableText/style.module.css.d.ts new file mode 100644 index 0000000..592d97e --- /dev/null +++ b/client/src/components/EditableText/style.module.css.d.ts @@ -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; diff --git a/client/src/components/EstimationRange.tsx b/client/src/components/EstimationRange.tsx new file mode 100644 index 0000000..9a72b5f --- /dev/null +++ b/client/src/components/EstimationRange.tsx @@ -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 = ({ 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 ( + + {`${e} ± ${sd}`}  + + + ); +} + +export default EstimationRange; \ No newline at end of file diff --git a/client/src/components/Navbar.tsx b/client/src/components/Navbar.tsx index fa8a634..d450aa0 100644 --- a/client/src/components/Navbar.tsx +++ b/client/src/components/Navbar.tsx @@ -20,7 +20,7 @@ export function Navbar() {

- Guesstimate + GuesstiMate

void +} + +const EstimationTab: FunctionComponent = ({ 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 ( + +
+
+ +
+
+ + +
+
+
+
+ +
+
+ { + Object.keys(project.tasks).length <= 20 ? +
+
+

⚠️ Attention

+

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.

+
+
: + null + } +
+
+ ); +}; + +export default EstimationTab; diff --git a/client/src/components/ProjectPage/ExportTab.tsx b/client/src/components/ProjectPage/ExportTab.tsx new file mode 100644 index 0000000..0ecbd6f --- /dev/null +++ b/client/src/components/ProjectPage/ExportTab.tsx @@ -0,0 +1,18 @@ +import React, { FunctionComponent } from "react"; +import { Project } from "../../types/project"; + +export interface ExportTabProps { + project: Project +} + +const ExportTab: FunctionComponent = ({ project }) => { + return ( +
+ +
{ JSON.stringify(project, null, 2) }
+
+
+ ); +}; + +export default ExportTab; diff --git a/client/src/components/ProjectPage/FinancielPreview.tsx b/client/src/components/ProjectPage/FinancielPreview.tsx new file mode 100644 index 0000000..5300733 --- /dev/null +++ b/client/src/components/ProjectPage/FinancielPreview.tsx @@ -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 = ({ project }) => { + const estimations = useProjectEstimations(project); + const costs = getMinMaxCosts(project, estimations.p99); + const roundUp = getRoundUpEstimations(project); + return ( +
+ + + + + + + + + + + + + + + + + + + + +
+ Prévisionnel financier
+ confiance >= 99.7% +
TempsCoût
Maximum + +
Minimum + +
+
+ ); +}; + +export interface CostDetailsProps { + project: Project + cost: Cost + roundUp: boolean +} + +export const CostDetails:FunctionComponent = ({ project, cost, roundUp }) => { + return ( +
+ + ≈ {cost.totalCost} {getCurrency(project)} + { roundUp ? Math.ceil(cost.totalTime) : cost.totalTime.toFixed(2) } + + + + { + Object.keys(cost.details).map(taskCategoryId => { + const taskCategory = project.params.taskCategories[taskCategoryId]; + const details = cost.details[taskCategoryId]; + return ( + + + + + + ) + }) + } + +
{taskCategory.label}{details.cost} {getCurrency(project)}{ roundUp ? Math.ceil(details.time) : details.time.toFixed(2) } × {getTaskCategoryCost(taskCategory)} {getCurrency(project)}
+
+ ); +}; + +export default FinancialPreview; diff --git a/client/src/components/ProjectPage/ParamsTab.tsx b/client/src/components/ProjectPage/ParamsTab.tsx new file mode 100644 index 0000000..392f99b --- /dev/null +++ b/client/src/components/ProjectPage/ParamsTab.tsx @@ -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 = ({ 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 ( + + +
+ + +
+
+
+ +
+ +
+ +
+ +
+
+
+ + +
+
+
+ +
+ +
+
+
+ +
+
+ +
+ + +
+ +
+
+
+ ); +}; + +export default ParamsTab; diff --git a/client/src/components/ProjectPage/ProjectPage.tsx b/client/src/components/ProjectPage/ProjectPage.tsx new file mode 100644 index 0000000..f3a5f8a --- /dev/null +++ b/client/src/components/ProjectPage/ProjectPage.tsx @@ -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 = () => { + const { id } = useParams(); + const [ project, dispatch ] = useProjectReducer(newProject()); + + const onProjectLabelChange = (projectLabel: string) => { + dispatch(updateProjectLabel(projectLabel)); + }; + + return ( + +
+
+
+ (

{value}

)} + onChange={onProjectLabelChange} + value={project.label ? project.label : "Projet sans nom"} + /> +
+ + }, + { + label: 'Options avancées', + icon: 'fa fa-sliders-h', + render: () => + }, + { + label: 'Exporter', + icon: 'fa fa-file-export', + render: () => + } + ]} /> +
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/client/src/components/ProjectPage/RepartitionPreview.tsx b/client/src/components/ProjectPage/RepartitionPreview.tsx new file mode 100644 index 0000000..caacc2c --- /dev/null +++ b/client/src/components/ProjectPage/RepartitionPreview.tsx @@ -0,0 +1,40 @@ +import React, { FunctionComponent } from "react"; +import { Project, getTaskCategoriesMeanRepartition } from "../../types/project"; + +export interface RepartitionPreviewProps { + project: Project +} + +const RepartitionPreview: FunctionComponent = ({ project }) => { + const repartition = getTaskCategoriesMeanRepartition(project); + return ( +
+ + + + + + + + + + + + { + Object.values(project.params.taskCategories).map(tc => { + let percent = (repartition[tc.id] * 100).toFixed(0); + return ( + + + + + ); + }) + } + +
Répartition moyenne
CatégorieTemps (en %)
{tc.label}{percent} %
+
+ ); +}; + +export default RepartitionPreview; diff --git a/client/src/components/ProjectPage/TaskCategorieTable.tsx b/client/src/components/ProjectPage/TaskCategorieTable.tsx new file mode 100644 index 0000000..9f10b65 --- /dev/null +++ b/client/src/components/ProjectPage/TaskCategorieTable.tsx @@ -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 = ({ 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 ( +
+ + + + + + + + + + + { + Object.values(project.params.taskCategories).map(tc => { + return ( + + + + + + ); + }) + } + + + + + + + +
CatégorieCoût par unité de temps
+ + + + + ({value} {getCurrency(project)})} + onChange={onTaskCategoryCostChange.bind(null, tc.id)} /> +
+
+

+ +

+

+ +

+

+ {getCurrency(project)} +

+

+ + Ajouter + +

+
+
+
+ ); +}; + +export default TaskCategoriesTable; diff --git a/client/src/components/ProjectPage/TasksTable.tsx b/client/src/components/ProjectPage/TasksTable.tsx new file mode 100644 index 0000000..708eb1b --- /dev/null +++ b/client/src/components/ProjectPage/TasksTable.tsx @@ -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 = ({ 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 ( +
+ + + + + + + + + + + + + + + + { + Object.values(project.tasks).map(t => { + const category = project.params.taskCategories[t.category]; + const categoryLabel = category ? category.label : '???'; + return ( + + + + + + + + + ) + }) + } + { + Object.keys(project.tasks).length === 0 ? + + + + : + null + } + + + + + + + + + + + + + + +
TâcheCatégorieEstimation (en )
OptimisteProbablePessimiste
+ + + ({value})} + onChange={onTaskLabelChange.bind(null, t.id)} + value={t.label} /> + { categoryLabel } + { + isPrint ? + {t.estimations.optimistic} : + + } + + { + isPrint ? + {t.estimations.likely} : + + } + + { + isPrint ? + {t.estimations.pessimistic} : + + } +
Aucune tâche pour l'instant.
+
+

+ +

+

+ + + +

+

+ + Ajouter + +

+
+
Total
{totals.optimistic} {totals.likely} {totals.pessimistic}
+
+ ); + }; + + export default TaskTable; diff --git a/client/src/components/ProjectPage/TimePreview.tsx b/client/src/components/ProjectPage/TimePreview.tsx new file mode 100644 index 0000000..2cfe276 --- /dev/null +++ b/client/src/components/ProjectPage/TimePreview.tsx @@ -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 = ({ project }) => { + const estimations = useProjectEstimations(project); + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Prévisionnel temps
ConfianceEstimation
>= 99.7%
>= 90%
>= 68%
+ ❓ Estimation à 3 points +
+
+ ); +}; diff --git a/client/src/components/ProjectPage/style.module.css b/client/src/components/ProjectPage/style.module.css new file mode 100644 index 0000000..915d7ed --- /dev/null +++ b/client/src/components/ProjectPage/style.module.css @@ -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; +} \ No newline at end of file diff --git a/client/src/components/ProjectPage/style.module.css.d.ts b/client/src/components/ProjectPage/style.module.css.d.ts new file mode 100644 index 0000000..24be9b6 --- /dev/null +++ b/client/src/components/ProjectPage/style.module.css.d.ts @@ -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; diff --git a/client/src/components/ProjectTimeUnit.tsx b/client/src/components/ProjectTimeUnit.tsx new file mode 100644 index 0000000..9fdec33 --- /dev/null +++ b/client/src/components/ProjectTimeUnit.tsx @@ -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 = ({ project }) => { + const timeUnit = getTimeUnit(project); + return ( + {timeUnit.acronym} + ); +}; + +export default ProjectTimeUnit; diff --git a/client/src/components/Tabs/Tabs.tsx b/client/src/components/Tabs/Tabs.tsx new file mode 100644 index 0000000..c536ca4 --- /dev/null +++ b/client/src/components/Tabs/Tabs.tsx @@ -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 = ({ items, ...props }) => { + const [ selectedTabIndex, setSelectedTabIndex ] = useState(0); + + const onTabClick = (tabIndex: number) => { + setSelectedTabIndex(tabIndex); + }; + + const selectedTab = items[selectedTabIndex]; + + return ( +
+ ); +}; + +export default Tabs; diff --git a/client/src/components/Tabs/style.module.css b/client/src/components/Tabs/style.module.css new file mode 100644 index 0000000..255d426 --- /dev/null +++ b/client/src/components/Tabs/style.module.css @@ -0,0 +1,8 @@ +.tabs { + display: inherit; +} + +.tabContent { + padding-top: 1em; + max-width: 100%; +} \ No newline at end of file diff --git a/client/src/components/Tabs/style.module.css.d.ts b/client/src/components/Tabs/style.module.css.d.ts new file mode 100644 index 0000000..29db20b --- /dev/null +++ b/client/src/components/Tabs/style.module.css.d.ts @@ -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; diff --git a/client/src/hooks/useMediaQuery.ts b/client/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000..d2b6ff0 --- /dev/null +++ b/client/src/hooks/useMediaQuery.ts @@ -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; +} \ No newline at end of file diff --git a/client/src/hooks/useProjectEstimations.ts b/client/src/hooks/useProjectEstimations.ts new file mode 100644 index 0000000..37bc049 --- /dev/null +++ b/client/src/hooks/useProjectEstimations.ts @@ -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; +} \ No newline at end of file diff --git a/client/src/hooks/useProjectReducer.ts b/client/src/hooks/useProjectReducer.ts new file mode 100644 index 0000000..e795308 --- /dev/null +++ b/client/src/hooks/useProjectReducer.ts @@ -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 + } + }; +} \ No newline at end of file diff --git a/client/src/index.tsx b/client/src/index.tsx index cee36c1..aa0d99b 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -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( diff --git a/client/src/sass/_all.scss b/client/src/sass/_all.scss deleted file mode 100644 index c08fa8c..0000000 --- a/client/src/sass/_all.scss +++ /dev/null @@ -1,3 +0,0 @@ -@import 'bulma/bulma.sass'; -@import '_base.scss'; -@import '_loader.scss'; \ No newline at end of file diff --git a/client/src/sass/_loader.scss b/client/src/sass/_loader.scss deleted file mode 100644 index 7e2b5d1..0000000 --- a/client/src/sass/_loader.scss +++ /dev/null @@ -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; - } - } diff --git a/client/src/sass/_base.scss b/client/src/style/index.css similarity index 96% rename from client/src/sass/_base.scss rename to client/src/style/index.css index 7b08f56..9a8d26c 100644 --- a/client/src/sass/_base.scss +++ b/client/src/style/index.css @@ -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; } diff --git a/client/src/style/index.css.d.ts b/client/src/style/index.css.d.ts new file mode 100644 index 0000000..e161215 --- /dev/null +++ b/client/src/style/index.css.d.ts @@ -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; diff --git a/client/src/types/decision.tsx b/client/src/types/decision.tsx deleted file mode 100644 index 9a86d44..0000000 --- a/client/src/types/decision.tsx +++ /dev/null @@ -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(), - }; -} \ No newline at end of file diff --git a/client/src/types/params.ts b/client/src/types/params.ts new file mode 100644 index 0000000..a28ea01 --- /dev/null +++ b/client/src/types/params.ts @@ -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; +} \ No newline at end of file diff --git a/client/src/types/project.ts b/client/src/types/project.ts new file mode 100644 index 0000000..50cf101 --- /dev/null +++ b/client/src/types/project.ts @@ -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 }; +} \ No newline at end of file diff --git a/client/src/types/task.ts b/client/src/types/task.ts new file mode 100644 index 0000000..672196c --- /dev/null +++ b/client/src/types/task.ts @@ -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; +} diff --git a/client/src/types/workgroup.ts b/client/src/types/workgroup.ts deleted file mode 100644 index 9546278..0000000 --- a/client/src/types/workgroup.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/client/webpack.config.js b/client/webpack.config.js index be40da5..cb1e06d 100644 --- a/client/webpack.config.js +++ b/client/webpack.config.js @@ -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: [